feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,14 @@ import { createServer } from './server.js';
|
||||
import { setupGracefulShutdown } from './utils/index.js';
|
||||
import {
|
||||
McpServerRepository,
|
||||
McpProfileRepository,
|
||||
SecretRepository,
|
||||
McpInstanceRepository,
|
||||
ProjectRepository,
|
||||
AuditLogRepository,
|
||||
} from './repositories/index.js';
|
||||
import {
|
||||
McpServerService,
|
||||
McpProfileService,
|
||||
SecretService,
|
||||
InstanceService,
|
||||
ProjectService,
|
||||
AuditLogService,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from './services/index.js';
|
||||
import {
|
||||
registerMcpServerRoutes,
|
||||
registerMcpProfileRoutes,
|
||||
registerSecretRoutes,
|
||||
registerInstanceRoutes,
|
||||
registerProjectRoutes,
|
||||
registerAuditLogRoutes,
|
||||
@@ -50,7 +50,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Repositories
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
const profileRepo = new McpProfileRepository(prisma);
|
||||
const secretRepo = new SecretRepository(prisma);
|
||||
const instanceRepo = new McpInstanceRepository(prisma);
|
||||
const projectRepo = new ProjectRepository(prisma);
|
||||
const auditLogRepo = new AuditLogRepository(prisma);
|
||||
@@ -60,15 +60,15 @@ async function main(): Promise<void> {
|
||||
|
||||
// Services
|
||||
const serverService = new McpServerService(serverRepo);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||
serverService.setInstanceService(instanceService);
|
||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
||||
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||
const secretService = new SecretService(secretRepo);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const metricsCollector = new MetricsCollector();
|
||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
||||
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
||||
|
||||
@@ -88,7 +88,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Routes
|
||||
registerMcpServerRoutes(app, serverService, instanceService);
|
||||
registerMcpProfileRoutes(app, profileService);
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
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 { McpProfileRepository } from './mcp-profile.repository.js';
|
||||
export { SecretRepository } from './secret.repository.js';
|
||||
export type { IProjectRepository } from './project.repository.js';
|
||||
export { ProjectRepository } from './project.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 { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
|
||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
||||
|
||||
export interface IMcpServerRepository {
|
||||
findAll(): Promise<McpServer[]>;
|
||||
@@ -20,12 +20,12 @@ export interface IMcpInstanceRepository {
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMcpProfileRepository {
|
||||
findAll(serverId?: string): Promise<McpProfile[]>;
|
||||
findById(id: string): Promise<McpProfile | null>;
|
||||
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
|
||||
create(data: CreateMcpProfileInput): Promise<McpProfile>;
|
||||
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
|
||||
export interface ISecretRepository {
|
||||
findAll(): Promise<Secret[]>;
|
||||
findById(id: string): Promise<Secret | null>;
|
||||
findByName(name: string): Promise<Secret | null>;
|
||||
create(data: CreateSecretInput): Promise<Secret>;
|
||||
update(id: string, data: UpdateSecretInput): Promise<Secret>;
|
||||
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,
|
||||
containerPort: data.containerPort ?? null,
|
||||
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.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ export interface IProjectRepository {
|
||||
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
|
||||
update(id: string, data: UpdateProjectInput): Promise<Project>;
|
||||
delete(id: string): Promise<void>;
|
||||
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
|
||||
getProfileIds(projectId: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class ProjectRepository implements IProjectRepository {
|
||||
@@ -48,22 +46,4 @@ export class ProjectRepository implements IProjectRepository {
|
||||
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<{
|
||||
Body: {
|
||||
password?: string;
|
||||
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects'>;
|
||||
};
|
||||
}>('/api/v1/backup', async (request) => {
|
||||
const opts: BackupOptions = {};
|
||||
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { registerHealthRoutes } from './health.js';
|
||||
export type { HealthDeps } from './health.js';
|
||||
export { registerMcpServerRoutes } from './mcp-servers.js';
|
||||
export { registerMcpProfileRoutes } from './mcp-profiles.js';
|
||||
export { registerSecretRoutes } from './secrets.js';
|
||||
export { registerProjectRoutes } from './projects.js';
|
||||
export { registerInstanceRoutes } from './instances.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);
|
||||
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 { encrypt, isSensitiveKey } from './crypto.js';
|
||||
import type { EncryptedPayload } from './crypto.js';
|
||||
@@ -10,7 +10,7 @@ export interface BackupBundle {
|
||||
createdAt: string;
|
||||
encrypted: boolean;
|
||||
servers: BackupServer[];
|
||||
profiles: BackupProfile[];
|
||||
secrets: BackupSecret[];
|
||||
projects: BackupProject[];
|
||||
encryptedSecrets?: EncryptedPayload;
|
||||
}
|
||||
@@ -22,39 +22,36 @@ export interface BackupServer {
|
||||
dockerImage: string | null;
|
||||
transport: string;
|
||||
repositoryUrl: string | null;
|
||||
envTemplate: unknown;
|
||||
env: unknown;
|
||||
}
|
||||
|
||||
export interface BackupProfile {
|
||||
export interface BackupSecret {
|
||||
name: string;
|
||||
serverName: string;
|
||||
permissions: unknown;
|
||||
envOverrides: unknown;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BackupProject {
|
||||
name: string;
|
||||
description: string;
|
||||
profileNames: string[];
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
password?: string;
|
||||
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects'>;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
constructor(
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private profileRepo: IMcpProfileRepository,
|
||||
private projectRepo: IProjectRepository,
|
||||
private secretRepo: ISecretRepository,
|
||||
) {}
|
||||
|
||||
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
||||
const resources = options?.resources ?? ['servers', 'profiles', 'projects'];
|
||||
const resources = options?.resources ?? ['servers', 'secrets', 'projects'];
|
||||
|
||||
let servers: BackupServer[] = [];
|
||||
let profiles: BackupProfile[] = [];
|
||||
let secrets: BackupSecret[] = [];
|
||||
let projects: BackupProject[] = [];
|
||||
|
||||
if (resources.includes('servers')) {
|
||||
@@ -66,44 +63,24 @@ export class BackupService {
|
||||
dockerImage: s.dockerImage,
|
||||
transport: s.transport,
|
||||
repositoryUrl: s.repositoryUrl,
|
||||
envTemplate: s.envTemplate,
|
||||
env: s.env,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('profiles')) {
|
||||
const allProfiles = await this.profileRepo.findAll();
|
||||
const serverMap = new Map<string, string>();
|
||||
const allServers = await this.serverRepo.findAll();
|
||||
for (const s of allServers) {
|
||||
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('secrets')) {
|
||||
const allSecrets = await this.secretRepo.findAll();
|
||||
secrets = allSecrets.map((s) => ({
|
||||
name: s.name,
|
||||
data: s.data as Record<string, string>,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('projects')) {
|
||||
const allProjects = await this.projectRepo.findAll();
|
||||
const allProfiles = await this.profileRepo.findAll();
|
||||
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,
|
||||
description: proj.description,
|
||||
profileNames: profileIds.map((id) => profileMap.get(id) ?? id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
projects = allProjects.map((proj) => ({
|
||||
name: proj.name,
|
||||
description: proj.description,
|
||||
}));
|
||||
}
|
||||
|
||||
const bundle: BackupBundle = {
|
||||
@@ -112,29 +89,26 @@ export class BackupService {
|
||||
createdAt: new Date().toISOString(),
|
||||
encrypted: false,
|
||||
servers,
|
||||
profiles,
|
||||
secrets,
|
||||
projects,
|
||||
};
|
||||
|
||||
if (options?.password) {
|
||||
// Collect sensitive values and encrypt them
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const profile of profiles) {
|
||||
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
const secretKey = `profile:${profile.name}:${key}`;
|
||||
secrets[secretKey] = value;
|
||||
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`;
|
||||
}
|
||||
if (options?.password && secrets.length > 0) {
|
||||
// Collect sensitive values from secrets and encrypt them
|
||||
const sensitiveData: Record<string, string> = {};
|
||||
for (const secret of secrets) {
|
||||
for (const [key, value] of Object.entries(secret.data)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
const secretKey = `secret:${secret.name}:${key}`;
|
||||
sensitiveData[secretKey] = value;
|
||||
secret.data[key] = `__ENCRYPTED:${secretKey}__`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(secrets).length > 0) {
|
||||
if (Object.keys(sensitiveData).length > 0) {
|
||||
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 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 type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.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 { decrypt } from './crypto.js';
|
||||
import type { BackupBundle } from './backup-service.js';
|
||||
@@ -13,8 +13,8 @@ export interface RestoreOptions {
|
||||
export interface RestoreResult {
|
||||
serversCreated: number;
|
||||
serversSkipped: number;
|
||||
profilesCreated: number;
|
||||
profilesSkipped: number;
|
||||
secretsCreated: number;
|
||||
secretsSkipped: number;
|
||||
projectsCreated: number;
|
||||
projectsSkipped: number;
|
||||
errors: string[];
|
||||
@@ -23,8 +23,8 @@ export interface RestoreResult {
|
||||
export class RestoreService {
|
||||
constructor(
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private profileRepo: IMcpProfileRepository,
|
||||
private projectRepo: IProjectRepository,
|
||||
private secretRepo: ISecretRepository,
|
||||
) {}
|
||||
|
||||
validateBundle(bundle: unknown): bundle is BackupBundle {
|
||||
@@ -33,7 +33,7 @@ export class RestoreService {
|
||||
return (
|
||||
typeof b['version'] === 'string' &&
|
||||
Array.isArray(b['servers']) &&
|
||||
Array.isArray(b['profiles']) &&
|
||||
Array.isArray(b['secrets']) &&
|
||||
Array.isArray(b['projects'])
|
||||
);
|
||||
}
|
||||
@@ -43,46 +43,42 @@ export class RestoreService {
|
||||
const result: RestoreResult = {
|
||||
serversCreated: 0,
|
||||
serversSkipped: 0,
|
||||
profilesCreated: 0,
|
||||
profilesSkipped: 0,
|
||||
secretsCreated: 0,
|
||||
secretsSkipped: 0,
|
||||
projectsCreated: 0,
|
||||
projectsSkipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Decrypt secrets if encrypted
|
||||
let secrets: Record<string, string> = {};
|
||||
let decryptedSecrets: Record<string, string> = {};
|
||||
if (bundle.encrypted && bundle.encryptedSecrets) {
|
||||
if (!options?.password) {
|
||||
result.errors.push('Backup is encrypted but no password provided');
|
||||
return result;
|
||||
}
|
||||
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 {
|
||||
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore secrets into profile envOverrides
|
||||
for (const profile of bundle.profiles) {
|
||||
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
|
||||
const secretKey = value.slice(12, -2);
|
||||
const decrypted = secrets[secretKey];
|
||||
if (decrypted !== undefined) {
|
||||
overrides[key] = decrypted;
|
||||
}
|
||||
// Restore encrypted values into secret data
|
||||
for (const secret of bundle.secrets) {
|
||||
for (const [key, value] of Object.entries(secret.data)) {
|
||||
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
|
||||
const secretKey = value.slice(12, -2);
|
||||
const decrypted = decryptedSecrets[secretKey];
|
||||
if (decrypted !== undefined) {
|
||||
secret.data[key] = decrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore servers
|
||||
const serverNameToId = new Map<string, string>();
|
||||
for (const server of bundle.servers) {
|
||||
try {
|
||||
const existing = await this.serverRepo.findByName(server.name);
|
||||
@@ -93,7 +89,6 @@ export class RestoreService {
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.serversSkipped++;
|
||||
serverNameToId.set(server.name, existing.id);
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
@@ -105,7 +100,6 @@ export class RestoreService {
|
||||
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
|
||||
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
|
||||
await this.serverRepo.update(existing.id, updateData);
|
||||
serverNameToId.set(server.name, existing.id);
|
||||
result.serversCreated++;
|
||||
continue;
|
||||
}
|
||||
@@ -115,66 +109,44 @@ export class RestoreService {
|
||||
description: server.description,
|
||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
replicas: (server as { replicas?: number }).replicas ?? 1,
|
||||
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
|
||||
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
||||
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
|
||||
const created = await this.serverRepo.create(createData);
|
||||
serverNameToId.set(server.name, created.id);
|
||||
result.serversCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore profiles
|
||||
const profileNameToId = new Map<string, string>();
|
||||
for (const profile of bundle.profiles) {
|
||||
// Restore secrets
|
||||
for (const secret of bundle.secrets) {
|
||||
try {
|
||||
const serverId = serverNameToId.get(profile.serverName);
|
||||
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);
|
||||
const existing = await this.secretRepo.findByName(secret.name);
|
||||
if (existing) {
|
||||
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;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.profilesSkipped++;
|
||||
profileNameToId.set(profile.name, existing.id);
|
||||
result.secretsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
await this.profileRepo.update(existing.id, {
|
||||
permissions: profile.permissions as string[],
|
||||
envOverrides: profile.envOverrides as Record<string, string>,
|
||||
});
|
||||
profileNameToId.set(profile.name, existing.id);
|
||||
result.profilesCreated++;
|
||||
await this.secretRepo.update(existing.id, { data: secret.data });
|
||||
result.secretsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.profileRepo.create({
|
||||
name: profile.name,
|
||||
serverId: sid,
|
||||
permissions: profile.permissions as string[],
|
||||
envOverrides: profile.envOverrides as Record<string, string>,
|
||||
await this.secretRepo.create({
|
||||
name: secret.name,
|
||||
data: secret.data,
|
||||
});
|
||||
profileNameToId.set(profile.name, created.id);
|
||||
result.profilesCreated++;
|
||||
result.secretsCreated++;
|
||||
} 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++;
|
||||
continue;
|
||||
}
|
||||
// overwrite - update and set profiles
|
||||
// overwrite
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.projectRepo.create({
|
||||
await this.projectRepo.create({
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
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++;
|
||||
} catch (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 { 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 { InstanceService, InvalidStateError } from './instance.service.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 { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
|
||||
export { DockerContainerManager } from './docker/container-manager.js';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { NotFoundError } from './mcp-server.service.js';
|
||||
import { resolveServerEnv } from './env-resolver.js';
|
||||
|
||||
export class InvalidStateError extends Error {
|
||||
readonly statusCode = 409;
|
||||
@@ -16,6 +17,7 @@ export class InstanceService {
|
||||
private instanceRepo: IMcpInstanceRepository,
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private orchestrator: McpOrchestrator,
|
||||
private secretRepo?: ISecretRepository,
|
||||
) {}
|
||||
|
||||
async list(serverId?: string): Promise<McpInstance[]> {
|
||||
@@ -162,6 +164,19 @@ export class InstanceService {
|
||||
spec.command = command;
|
||||
}
|
||||
|
||||
// Resolve env vars from inline values and secret refs
|
||||
if (this.secretRepo) {
|
||||
try {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
}
|
||||
} catch (envErr) {
|
||||
// Log but don't prevent startup — env resolution failures are non-fatal
|
||||
// The container may still work if env vars are optional
|
||||
}
|
||||
}
|
||||
|
||||
const containerInfo = await this.orchestrator.createContainer(spec);
|
||||
|
||||
const updateFields: { containerId: string; port?: number } = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { McpServer, McpProfile } from '@prisma/client';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
export interface McpConfigServer {
|
||||
command: string;
|
||||
@@ -10,49 +10,25 @@ export interface McpConfig {
|
||||
mcpServers: Record<string, McpConfigServer>;
|
||||
}
|
||||
|
||||
export interface ProfileWithServer {
|
||||
profile: McpProfile;
|
||||
server: McpServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .mcp.json config from a project's profiles.
|
||||
* Secret env vars are excluded from the output — they must be injected at runtime.
|
||||
* Generate .mcp.json config from servers with their resolved env vars.
|
||||
*/
|
||||
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig {
|
||||
export function generateMcpConfig(
|
||||
servers: Array<{ server: McpServer; resolvedEnv: Record<string, string> }>,
|
||||
): McpConfig {
|
||||
const mcpServers: Record<string, McpConfigServer> = {};
|
||||
|
||||
for (const { profile, server } of profiles) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
for (const { server, resolvedEnv } of servers) {
|
||||
const config: McpConfigServer = {
|
||||
command: 'npx',
|
||||
args: ['-y', server.packageName ?? server.name],
|
||||
};
|
||||
|
||||
if (Object.keys(env).length > 0) {
|
||||
config.env = env;
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
config.env = resolvedEnv;
|
||||
}
|
||||
|
||||
mcpServers[key] = config;
|
||||
mcpServers[server.name] = config;
|
||||
}
|
||||
|
||||
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 { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js';
|
||||
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.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 {
|
||||
constructor(
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
private readonly profileRepo: IMcpProfileRepository,
|
||||
private readonly serverRepo: IMcpServerRepository,
|
||||
) {}
|
||||
|
||||
@@ -46,41 +43,4 @@ export class ProjectService {
|
||||
await this.getById(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 type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
|
||||
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js';
|
||||
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js';
|
||||
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js';
|
||||
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js';
|
||||
export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
|
||||
export type { CreateProjectInput, UpdateProjectInput } 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';
|
||||
|
||||
const EnvTemplateEntrySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).default(''),
|
||||
isSecret: z.boolean().default(false),
|
||||
setupUrl: z.string().url().optional(),
|
||||
const SecretRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
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({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
description: z.string().max(1000).default(''),
|
||||
@@ -18,7 +29,7 @@ export const CreateMcpServerSchema = z.object({
|
||||
command: z.array(z.string()).optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
});
|
||||
|
||||
export const UpdateMcpServerSchema = z.object({
|
||||
@@ -31,7 +42,7 @@ export const UpdateMcpServerSchema = z.object({
|
||||
command: z.array(z.string()).nullable().optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
replicas: z.number().int().min(0).max(10).optional(),
|
||||
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
|
||||
env: z.array(ServerEnvEntrySchema).optional(),
|
||||
});
|
||||
|
||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||
|
||||
@@ -9,10 +9,5 @@ export const UpdateProjectSchema = z.object({
|
||||
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 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 { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.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';
|
||||
|
||||
// Mock data
|
||||
@@ -12,19 +12,19 @@ const mockServers = [
|
||||
{
|
||||
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
|
||||
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,
|
||||
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'],
|
||||
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
|
||||
id: 'sec1', name: 'github-secrets',
|
||||
data: { GITHUB_TOKEN: 'ghp_secret123' },
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
|
||||
findAll: vi.fn(async () => [...mockServers]),
|
||||
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),
|
||||
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 })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProfileRepo(): IMcpProfileRepository {
|
||||
function mockSecretRepo(): ISecretRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [...mockProfiles]),
|
||||
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
|
||||
findByServerAndName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })),
|
||||
findAll: vi.fn(async () => [...mockSecrets]),
|
||||
findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
|
||||
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) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
|
||||
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])),
|
||||
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
setProfiles: vi.fn(async () => {}),
|
||||
getProfileIds: vi.fn(async () => ['p1']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +110,7 @@ describe('BackupService', () => {
|
||||
let backupService: BackupService;
|
||||
|
||||
beforeEach(() => {
|
||||
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo());
|
||||
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
|
||||
});
|
||||
|
||||
it('creates backup with all resources', async () => {
|
||||
@@ -121,43 +119,43 @@ describe('BackupService', () => {
|
||||
expect(bundle.version).toBe('1');
|
||||
expect(bundle.encrypted).toBe(false);
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.profiles).toHaveLength(1);
|
||||
expect(bundle.secrets).toHaveLength(1);
|
||||
expect(bundle.projects).toHaveLength(1);
|
||||
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');
|
||||
});
|
||||
|
||||
it('filters resources', async () => {
|
||||
const bundle = await backupService.createBackup({ resources: ['servers'] });
|
||||
expect(bundle.servers).toHaveLength(2);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.secrets).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' });
|
||||
|
||||
expect(bundle.encrypted).toBe(true);
|
||||
expect(bundle.encryptedSecrets).toBeDefined();
|
||||
// The GITHUB_TOKEN should be replaced with placeholder
|
||||
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
|
||||
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
|
||||
const data = bundle.secrets[0]!.data;
|
||||
expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
|
||||
});
|
||||
|
||||
it('handles empty repositories', async () => {
|
||||
const emptyServerRepo = mockServerRepo();
|
||||
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProfileRepo = mockProfileRepo();
|
||||
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptySecretRepo = mockSecretRepo();
|
||||
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const emptyProjectRepo = mockProjectRepo();
|
||||
(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();
|
||||
|
||||
expect(bundle.servers).toHaveLength(0);
|
||||
expect(bundle.profiles).toHaveLength(0);
|
||||
expect(bundle.secrets).toHaveLength(0);
|
||||
expect(bundle.projects).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -165,18 +163,18 @@ describe('BackupService', () => {
|
||||
describe('RestoreService', () => {
|
||||
let restoreService: RestoreService;
|
||||
let serverRepo: IMcpServerRepository;
|
||||
let profileRepo: IMcpProfileRepository;
|
||||
let secretRepo: ISecretRepository;
|
||||
let projectRepo: IProjectRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
serverRepo = mockServerRepo();
|
||||
profileRepo = mockProfileRepo();
|
||||
secretRepo = mockSecretRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
// Default: nothing exists yet
|
||||
(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);
|
||||
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||
});
|
||||
|
||||
const validBundle = {
|
||||
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
|
||||
mcpctlVersion: '0.1.0',
|
||||
createdAt: new Date().toISOString(),
|
||||
encrypted: false,
|
||||
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }],
|
||||
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
|
||||
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
|
||||
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
|
||||
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
|
||||
projects: [{ name: 'test-proj', description: 'Test' }],
|
||||
};
|
||||
|
||||
it('validates valid bundle', () => {
|
||||
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
|
||||
const result = await restoreService.restore(validBundle);
|
||||
|
||||
expect(result.serversCreated).toBe(1);
|
||||
expect(result.profilesCreated).toBe(1);
|
||||
expect(result.secretsCreated).toBe(1);
|
||||
expect(result.projectsCreated).toBe(1);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(serverRepo.create).toHaveBeenCalled();
|
||||
expect(profileRepo.create).toHaveBeenCalled();
|
||||
expect(secretRepo.create).toHaveBeenCalled();
|
||||
expect(projectRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
|
||||
});
|
||||
|
||||
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 = {
|
||||
...validBundle,
|
||||
encrypted: true,
|
||||
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
|
||||
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
|
||||
encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
|
||||
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
|
||||
};
|
||||
|
||||
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.profilesCreated).toBe(1);
|
||||
expect(result.secretsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('fails with wrong decryption password', async () => {
|
||||
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const sRepo = mockServerRepo();
|
||||
const pRepo = mockProfileRepo();
|
||||
const secRepo = mockSecretRepo();
|
||||
const prRepo = mockProjectRepo();
|
||||
backupService = new BackupService(sRepo, pRepo, prRepo);
|
||||
backupService = new BackupService(sRepo, prRepo, secRepo);
|
||||
|
||||
const rSRepo = mockServerRepo();
|
||||
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPRepo = mockProfileRepo();
|
||||
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rSecRepo = mockSecretRepo();
|
||||
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rPrRepo = mockProjectRepo();
|
||||
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
|
||||
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
|
||||
});
|
||||
|
||||
async function buildApp() {
|
||||
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
|
||||
const body = res.json();
|
||||
expect(body.version).toBe('1');
|
||||
expect(body.servers).toBeDefined();
|
||||
expect(body.profiles).toBeDefined();
|
||||
expect(body.secrets).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,
|
||||
containerPort: overrides.containerPort ?? null,
|
||||
replicas: overrides.replicas ?? 1,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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'] {
|
||||
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'] {
|
||||
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||
return {
|
||||
id: 's1',
|
||||
name: 'slack',
|
||||
@@ -25,7 +11,7 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -34,76 +20,51 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
|
||||
}
|
||||
|
||||
describe('generateMcpConfig', () => {
|
||||
it('returns empty mcpServers for empty profiles', () => {
|
||||
it('returns empty mcpServers for empty input', () => {
|
||||
const result = generateMcpConfig([]);
|
||||
expect(result).toEqual({ mcpServers: {} });
|
||||
});
|
||||
|
||||
it('generates config for a single profile', () => {
|
||||
it('generates config for a single server', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile(), server: makeServer() },
|
||||
{ server: makeServer(), resolvedEnv: {} },
|
||||
]);
|
||||
expect(result.mcpServers['slack--default']).toBeDefined();
|
||||
expect(result.mcpServers['slack--default']?.command).toBe('npx');
|
||||
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
|
||||
expect(result.mcpServers['slack']).toBeDefined();
|
||||
expect(result.mcpServers['slack']?.command).toBe('npx');
|
||||
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
|
||||
});
|
||||
|
||||
it('excludes secret env vars from output', () => {
|
||||
const server = makeServer({
|
||||
envTemplate: [
|
||||
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
|
||||
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
|
||||
] as never,
|
||||
});
|
||||
it('includes resolved env when present', () => {
|
||||
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?.['SLACK_TEAM_ID']).toBe('T123');
|
||||
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies env overrides from profile (non-secret only)', () => {
|
||||
const server = makeServer({
|
||||
envTemplate: [
|
||||
{ name: 'API_URL', description: 'URL', isSecret: false },
|
||||
] as never,
|
||||
});
|
||||
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('omits env when resolvedEnv is empty', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ server: makeServer(), resolvedEnv: {} },
|
||||
]);
|
||||
expect(result.mcpServers['slack']?.env).toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates multiple server configs', () => {
|
||||
const result = generateMcpConfig([
|
||||
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
|
||||
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
|
||||
{ server: makeServer({ name: 'slack' }), resolvedEnv: {} },
|
||||
{ server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }), resolvedEnv: {} },
|
||||
]);
|
||||
expect(Object.keys(result.mcpServers)).toHaveLength(2);
|
||||
expect(result.mcpServers['slack--readonly']).toBeDefined();
|
||||
expect(result.mcpServers['github--default']).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();
|
||||
expect(result.mcpServers['slack']).toBeDefined();
|
||||
expect(result.mcpServers['github']).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses server name as fallback when packageName is null', () => {
|
||||
const server = makeServer({ packageName: null });
|
||||
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,
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas ?? 1,
|
||||
envTemplate: data.envTemplate ?? [],
|
||||
env: data.env ?? [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -347,8 +347,8 @@ describe('MCP server full flow', () => {
|
||||
transport: 'STREAMABLE_HTTP',
|
||||
externalUrl: `http://localhost:${fakeMcpPort}`,
|
||||
containerPort: 3000,
|
||||
envTemplate: [
|
||||
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
||||
env: [
|
||||
{ name: 'HOMEASSISTANT_TOKEN', value: 'placeholder' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -463,9 +463,9 @@ describe('MCP server full flow', () => {
|
||||
transport: 'STREAMABLE_HTTP',
|
||||
containerPort: 3000,
|
||||
command: ['python', '-c', 'print("hello")'],
|
||||
envTemplate: [
|
||||
{ name: 'HOMEASSISTANT_URL', description: 'HA URL' },
|
||||
{ name: 'HOMEASSISTANT_TOKEN', description: 'HA token', isSecret: true },
|
||||
env: [
|
||||
{ name: 'HOMEASSISTANT_URL', value: 'http://localhost:8123' },
|
||||
{ name: 'HOMEASSISTANT_TOKEN', valueFrom: { secretRef: { name: 'ha-secrets', key: 'token' } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function mockRepo(): IMcpServerRepository {
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: data.replicas ?? 1,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -55,7 +55,7 @@ function mockRepo(): IMcpServerRepository {
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -15,7 +15,7 @@ function mockRepo(): IMcpServerRepository {
|
||||
dockerImage: null,
|
||||
transport: data.transport ?? 'STDIO',
|
||||
repositoryUrl: data.repositoryUrl ?? null,
|
||||
envTemplate: data.envTemplate ?? [],
|
||||
env: data.env ?? [],
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -28,7 +28,7 @@ function mockRepo(): IMcpServerRepository {
|
||||
dockerImage: null,
|
||||
transport: 'STDIO' as const,
|
||||
repositoryUrl: null,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
version: 2,
|
||||
createdAt: 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 { NotFoundError, ConflictError } from '../src/services/mcp-server.service.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 {
|
||||
return {
|
||||
@@ -23,19 +23,6 @@ function mockProjectRepo(): IProjectRepository {
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
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', () => {
|
||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||
let profileRepo: ReturnType<typeof mockProfileRepo>;
|
||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||
let service: ProjectService;
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepo = mockProjectRepo();
|
||||
profileRepo = mockProfileRepo();
|
||||
serverRepo = mockServerRepo();
|
||||
service = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||
service = new ProjectService(projectRepo, serverRepo);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('deletes project', async () => {
|
||||
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 {
|
||||
CreateMcpServerSchema,
|
||||
UpdateMcpServerSchema,
|
||||
CreateMcpProfileSchema,
|
||||
UpdateMcpProfileSchema,
|
||||
} from '../src/validation/index.js';
|
||||
|
||||
describe('CreateMcpServerSchema', () => {
|
||||
@@ -14,7 +12,7 @@ describe('CreateMcpServerSchema', () => {
|
||||
transport: 'STDIO',
|
||||
});
|
||||
expect(result.name).toBe('my-server');
|
||||
expect(result.envTemplate).toEqual([]);
|
||||
expect(result.env).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
@@ -39,15 +37,40 @@ describe('CreateMcpServerSchema', () => {
|
||||
expect(result.transport).toBe('STDIO');
|
||||
});
|
||||
|
||||
it('validates envTemplate entries', () => {
|
||||
it('validates env entries with inline value', () => {
|
||||
const result = CreateMcpServerSchema.parse({
|
||||
name: 'test',
|
||||
envTemplate: [
|
||||
{ name: 'API_KEY', description: 'The key', isSecret: true },
|
||||
env: [
|
||||
{ name: 'API_URL', value: 'https://example.com' },
|
||||
],
|
||||
});
|
||||
expect(result.envTemplate).toHaveLength(1);
|
||||
expect(result.envTemplate[0]?.isSecret).toBe(true);
|
||||
expect(result.env).toHaveLength(1);
|
||||
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', () => {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user