From ca02340a4cbbe1ce581f55995258e515365efb45 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 18:40:58 +0000 Subject: [PATCH] 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 --- docs/getting-started.md | 8 +- examples/ha-mcp.yaml | 10 +- src/cli/src/commands/apply.ts | 69 +++---- src/cli/src/commands/create.ts | 69 +++---- src/cli/src/commands/describe.ts | 90 +++++----- src/cli/src/commands/edit.ts | 6 +- src/cli/src/commands/get.ts | 40 ++--- src/cli/src/commands/project.ts | 37 ---- src/cli/src/commands/setup.ts | 103 ----------- src/cli/src/commands/shared.ts | 4 +- src/cli/src/index.ts | 28 --- src/cli/tests/commands/apply.test.ts | 64 ++++--- src/cli/tests/commands/create.test.ts | 63 +++---- src/cli/tests/commands/describe.test.ts | 61 ++++--- src/cli/tests/commands/edit.test.ts | 27 --- src/cli/tests/commands/get.test.ts | 10 -- src/cli/tests/commands/instances.test.ts | 6 - src/cli/tests/commands/project.test.ts | 31 +--- src/cli/tests/commands/setup.test.ts | 141 --------------- src/cli/tests/e2e/cli-commands.test.ts | 13 +- src/db/prisma/schema.prisma | 43 ++--- src/db/src/index.ts | 3 +- src/db/src/seed/index.ts | 55 +----- src/db/tests/helpers.ts | 3 +- src/db/tests/models.test.ts | 131 ++++---------- src/db/tests/seed.test.ts | 10 +- src/mcpd/src/main.ts | 20 +-- src/mcpd/src/repositories/index.ts | 4 +- src/mcpd/src/repositories/interfaces.ts | 16 +- .../repositories/mcp-profile.repository.ts | 46 ----- .../src/repositories/mcp-server.repository.ts | 4 +- .../src/repositories/project.repository.ts | 20 --- .../src/repositories/secret.repository.ts | 39 ++++ src/mcpd/src/routes/backup.ts | 4 +- src/mcpd/src/routes/index.ts | 2 +- src/mcpd/src/routes/mcp-profiles.ts | 27 --- src/mcpd/src/routes/projects.ts | 14 -- src/mcpd/src/routes/secrets.ts | 30 ++++ .../src/services/backup/backup-service.ts | 88 ++++----- src/mcpd/src/services/backup/index.ts | 2 +- .../src/services/backup/restore-service.ts | 104 ++++------- src/mcpd/src/services/env-resolver.ts | 44 +++++ src/mcpd/src/services/index.ts | 5 +- src/mcpd/src/services/instance.service.ts | 17 +- src/mcpd/src/services/mcp-config-generator.ts | 42 +---- src/mcpd/src/services/mcp-profile.service.ts | 62 ------- src/mcpd/src/services/project.service.ts | 44 +---- src/mcpd/src/services/secret.service.ts | 54 ++++++ src/mcpd/src/validation/index.ts | 6 +- src/mcpd/src/validation/mcp-profile.schema.ts | 17 -- src/mcpd/src/validation/mcp-server.schema.ts | 25 ++- src/mcpd/src/validation/project.schema.ts | 5 - src/mcpd/src/validation/secret.schema.ts | 13 ++ src/mcpd/tests/backup.test.ts | 88 +++++---- src/mcpd/tests/env-resolver.test.ts | 112 ++++++++++++ src/mcpd/tests/instance-service.test.ts | 2 +- src/mcpd/tests/mcp-config-generator.test.ts | 85 +++------ src/mcpd/tests/mcp-profile-service.test.ts | 128 ------------- src/mcpd/tests/mcp-server-flow.test.ts | 12 +- src/mcpd/tests/mcp-server-routes.test.ts | 4 +- src/mcpd/tests/mcp-server-service.test.ts | 4 +- src/mcpd/tests/project-service.test.ts | 68 +------ src/mcpd/tests/secret-routes.test.ts | 170 ++++++++++++++++++ src/mcpd/tests/validation.test.ts | 83 ++++----- src/shared/src/index.ts | 1 - src/shared/src/profiles/index.ts | 5 - src/shared/src/profiles/registry.ts | 67 ------- src/shared/src/profiles/templates/fetch.ts | 15 -- .../src/profiles/templates/filesystem.ts | 16 -- src/shared/src/profiles/templates/github.ts | 22 --- src/shared/src/profiles/templates/index.ts | 6 - src/shared/src/profiles/templates/memory.ts | 15 -- src/shared/src/profiles/templates/postgres.ts | 21 --- src/shared/src/profiles/templates/slack.ts | 28 --- src/shared/src/profiles/types.ts | 35 ---- src/shared/src/profiles/utils.ts | 61 ------- src/shared/src/types/index.ts | 18 +- 77 files changed, 1014 insertions(+), 1931 deletions(-) delete mode 100644 src/cli/src/commands/setup.ts delete mode 100644 src/cli/tests/commands/setup.test.ts delete mode 100644 src/mcpd/src/repositories/mcp-profile.repository.ts create mode 100644 src/mcpd/src/repositories/secret.repository.ts delete mode 100644 src/mcpd/src/routes/mcp-profiles.ts create mode 100644 src/mcpd/src/routes/secrets.ts create mode 100644 src/mcpd/src/services/env-resolver.ts delete mode 100644 src/mcpd/src/services/mcp-profile.service.ts create mode 100644 src/mcpd/src/services/secret.service.ts delete mode 100644 src/mcpd/src/validation/mcp-profile.schema.ts create mode 100644 src/mcpd/src/validation/secret.schema.ts create mode 100644 src/mcpd/tests/env-resolver.test.ts delete mode 100644 src/mcpd/tests/mcp-profile-service.test.ts create mode 100644 src/mcpd/tests/secret-routes.test.ts delete mode 100644 src/shared/src/profiles/index.ts delete mode 100644 src/shared/src/profiles/registry.ts delete mode 100644 src/shared/src/profiles/templates/fetch.ts delete mode 100644 src/shared/src/profiles/templates/filesystem.ts delete mode 100644 src/shared/src/profiles/templates/github.ts delete mode 100644 src/shared/src/profiles/templates/index.ts delete mode 100644 src/shared/src/profiles/templates/memory.ts delete mode 100644 src/shared/src/profiles/templates/postgres.ts delete mode 100644 src/shared/src/profiles/templates/slack.ts delete mode 100644 src/shared/src/profiles/types.ts delete mode 100644 src/shared/src/profiles/utils.ts diff --git a/docs/getting-started.md b/docs/getting-started.md index 0b736b0..8262357 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -96,10 +96,12 @@ servers: description: Slack MCP server transport: STDIO packageName: "@anthropic/slack-mcp" - envTemplate: + env: - name: SLACK_TOKEN - description: Slack bot token - isSecret: true + valueFrom: + secretRef: + name: slack-secrets + key: token - name: github description: GitHub MCP server diff --git a/examples/ha-mcp.yaml b/examples/ha-mcp.yaml index c4a03ad..01bb5d1 100644 --- a/examples/ha-mcp.yaml +++ b/examples/ha-mcp.yaml @@ -11,12 +11,14 @@ servers: - "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)" # For connecting to an already-running instance (host.containers.internal for container-to-host): externalUrl: "http://host.containers.internal:8086/mcp" - envTemplate: + env: - name: HOMEASSISTANT_URL - description: "Home Assistant instance URL (e.g. https://ha.example.com)" + value: "" - name: HOMEASSISTANT_TOKEN - description: "Home Assistant long-lived access token" - isSecret: true + valueFrom: + secretRef: + name: ha-secrets + key: token profiles: - name: production diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index e731022..55506a9 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -4,6 +4,14 @@ import yaml from 'js-yaml'; import { z } from 'zod'; import type { ApiClient } from '../api-client.js'; +const ServerEnvEntrySchema = z.object({ + name: z.string().min(1), + value: z.string().optional(), + valueFrom: z.object({ + secretRef: z.object({ name: z.string(), key: z.string() }), + }).optional(), +}); + const ServerSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), @@ -15,29 +23,22 @@ const ServerSpecSchema = 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(z.object({ - name: z.string(), - description: z.string().default(''), - isSecret: z.boolean().default(false), - })).default([]), + env: z.array(ServerEnvEntrySchema).default([]), }); -const ProfileSpecSchema = z.object({ +const SecretSpecSchema = z.object({ name: z.string().min(1), - server: z.string().min(1), - permissions: z.array(z.string()).default([]), - envOverrides: z.record(z.string()).default({}), + data: z.record(z.string()).default({}), }); const ProjectSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), - profiles: z.array(z.string()).default([]), }); const ApplyConfigSchema = z.object({ servers: z.array(ServerSpecSchema).default([]), - profiles: z.array(ProfileSpecSchema).default([]), + secrets: z.array(SecretSpecSchema).default([]), projects: z.array(ProjectSpecSchema).default([]), }); @@ -61,7 +62,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { if (opts.dryRun) { log('Dry run - would apply:'); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); - if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`); + if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`); if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); return; } @@ -84,7 +85,7 @@ function loadConfigFile(path: string): ApplyConfig { } async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise { - // Apply servers first (profiles depend on servers) + // Apply servers first for (const server of config.servers) { try { const existing = await findByName(client, 'servers', server.name); @@ -100,34 +101,19 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } - // Apply profiles (need server IDs) - for (const profile of config.profiles) { + // Apply secrets + for (const secret of config.secrets) { try { - const server = await findByName(client, 'servers', profile.server); - if (!server) { - log(`Skipping profile '${profile.name}': server '${profile.server}' not found`); - continue; - } - const serverId = (server as { id: string }).id; - - const existing = await findProfile(client, serverId, profile.name); + const existing = await findByName(client, 'secrets', secret.name); if (existing) { - await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, { - permissions: profile.permissions, - envOverrides: profile.envOverrides, - }); - log(`Updated profile: ${profile.name} (server: ${profile.server})`); + await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data }); + log(`Updated secret: ${secret.name}`); } else { - await client.post('/api/v1/profiles', { - name: profile.name, - serverId, - permissions: profile.permissions, - envOverrides: profile.envOverrides, - }); - log(`Created profile: ${profile.name} (server: ${profile.server})`); + await client.post('/api/v1/secrets', secret); + log(`Created secret: ${secret.name}`); } } catch (err) { - log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`); + log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`); } } @@ -162,16 +148,5 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr } } -async function findProfile(client: ApiClient, serverId: string, name: string): Promise { - try { - const profiles = await client.get>( - `/api/v1/profiles?serverId=${serverId}`, - ); - return profiles.find((p) => p.name === name) ?? null; - } catch { - return null; - } -} - // Export for testing export { loadConfigFile, applyConfig }; diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index f29a79f..7c0d768 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -1,7 +1,5 @@ import { Command } from 'commander'; import type { ApiClient } from '../api-client.js'; -import { resolveNameOrId } from './shared.js'; - export interface CreateCommandDeps { client: ApiClient; log: (...args: unknown[]) => void; @@ -11,17 +9,33 @@ function collect(value: string, prev: string[]): string[] { return [...prev, value]; } -function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> { +interface ServerEnvEntry { + name: string; + value?: string; + valueFrom?: { secretRef: { name: string; key: string } }; +} + +function parseServerEnv(entries: string[]): ServerEnvEntry[] { return entries.map((entry) => { - const parts = entry.split(':'); - if (parts.length < 2) { - throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`); + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) { + throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`); } - return { - name: parts[0]!, - description: parts[1]!, - isSecret: parts[2] === 'true', - }; + const envName = entry.slice(0, eqIdx); + const rhs = entry.slice(eqIdx + 1); + + if (rhs.startsWith('secretRef:')) { + const parts = rhs.split(':'); + if (parts.length !== 3) { + throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`); + } + return { + name: envName, + valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } }, + }; + } + + return { name: envName, value: rhs }; }); } @@ -41,7 +55,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, profile, project)'); + .description('Create a resource (server, project)'); // --- create server --- cmd.command('server') @@ -56,7 +70,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--command ', 'Command argument (repeat for multiple)', collect, []) .option('--container-port ', 'Container port number') .option('--replicas ', 'Number of replicas', '1') - .option('--env-template ', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, []) + .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) .action(async (name: string, opts) => { const body: Record = { name, @@ -70,31 +84,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.externalUrl) body.externalUrl = opts.externalUrl; if (opts.command.length > 0) body.command = opts.command; if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); - if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate); + if (opts.env.length > 0) body.env = parseServerEnv(opts.env); const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body); log(`server '${server.name}' created (id: ${server.id})`); }); - // --- create profile --- - cmd.command('profile') - .description('Create a profile for an MCP server') - .argument('', 'Profile name') - .requiredOption('--server ', 'Server name or ID') - .option('--permissions ', 'Permission (repeat for multiple)', collect, []) - .option('--env ', 'Environment override KEY=value (repeat for multiple)', collect, []) + // --- create secret --- + cmd.command('secret') + .description('Create a secret') + .argument('', 'Secret name (lowercase, hyphens allowed)') + .option('--data ', 'Secret data KEY=value (repeat for multiple)', collect, []) .action(async (name: string, opts) => { - const serverId = await resolveNameOrId(client, 'servers', opts.server); - - const body: Record = { + const data = parseEnvEntries(opts.data); + const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', { name, - serverId, - }; - if (opts.permissions.length > 0) body.permissions = opts.permissions; - if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env); - - const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body); - log(`profile '${profile.name}' created (id: ${profile.id})`); + data, + }); + log(`secret '${secret.name}' created (id: ${secret.id})`); }); // --- create project --- diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 79114fc..7126c8b 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -34,15 +34,19 @@ function formatServerDetail(server: Record): string { lines.push(` ${command.join(' ')}`); } - const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined; - if (envTemplate && envTemplate.length > 0) { + const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined; + if (env && env.length > 0) { lines.push(''); - lines.push('Environment Template:'); - const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2; - const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2; - lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`); - for (const env of envTemplate) { - lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`); + lines.push('Environment:'); + const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2; + lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`); + for (const e of env) { + if (e.value !== undefined) { + lines.push(` ${e.name.padEnd(nameW)}${e.value}`); + } else if (e.valueFrom?.secretRef) { + const ref = e.valueFrom.secretRef; + lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`); + } } } @@ -92,36 +96,6 @@ function formatInstanceDetail(instance: Record, inspect?: Recor return lines.join('\n'); } -function formatProfileDetail(profile: Record): string { - const lines: string[] = []; - lines.push(`=== Profile: ${profile.name} ===`); - lines.push(`${pad('Name:')}${profile.name}`); - lines.push(`${pad('Server ID:')}${profile.serverId}`); - - const permissions = profile.permissions as string[] | undefined; - if (permissions && permissions.length > 0) { - lines.push(`${pad('Permissions:')}${permissions.join(', ')}`); - } - - const envOverrides = profile.envOverrides as Record | undefined; - if (envOverrides && Object.keys(envOverrides).length > 0) { - lines.push(''); - lines.push('Environment Overrides:'); - const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2; - for (const [key, value] of Object.entries(envOverrides)) { - lines.push(` ${key.padEnd(keyW)}${value}`); - } - } - - lines.push(''); - lines.push('Metadata:'); - lines.push(` ${pad('ID:', 12)}${profile.id}`); - if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`); - if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`); - - return lines.join('\n'); -} - function formatProjectDetail(project: Record): string { const lines: string[] = []; lines.push(`=== Project: ${project.name} ===`); @@ -138,6 +112,37 @@ function formatProjectDetail(project: Record): string { return lines.join('\n'); } +function formatSecretDetail(secret: Record, showValues: boolean): string { + const lines: string[] = []; + lines.push(`=== Secret: ${secret.name} ===`); + lines.push(`${pad('Name:')}${secret.name}`); + + const data = secret.data as Record | undefined; + if (data && Object.keys(data).length > 0) { + lines.push(''); + lines.push('Data:'); + const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2; + for (const [key, value] of Object.entries(data)) { + const display = showValues ? value : '***'; + lines.push(` ${key.padEnd(keyW)}${display}`); + } + if (!showValues) { + lines.push(''); + lines.push(' (use --show-values to reveal)'); + } + } else { + lines.push(`${pad('Data:')}(empty)`); + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${secret.id}`); + if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`); + if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`); + + return lines.join('\n'); +} + function formatGenericDetail(obj: Record): string { const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { @@ -167,10 +172,11 @@ function formatGenericDetail(obj: Record): string { export function createDescribeCommand(deps: DescribeCommandDeps): Command { return new Command('describe') .description('Show detailed information about a resource') - .argument('', 'resource type (server, profile, project, instance)') + .argument('', 'resource type (server, project, instance)') .argument('', 'resource ID or name') .option('-o, --output ', 'output format (detail, json, yaml)', 'detail') - .action(async (resourceArg: string, idOrName: string, opts: { output: string }) => { + .option('--show-values', 'Show secret values (default: masked)') + .action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => { const resource = resolveResource(resourceArg); // Resolve name → ID @@ -207,8 +213,8 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'instances': deps.log(formatInstanceDetail(item, inspect)); break; - case 'profiles': - deps.log(formatProfileDetail(item)); + case 'secrets': + deps.log(formatSecretDetail(item, opts.showValues === true)); break; case 'projects': deps.log(formatProjectDetail(item)); diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index 3673dcd..ae8134c 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -33,8 +33,8 @@ export function createEditCommand(deps: EditCommandDeps): Command { const { client, log } = deps; return new Command('edit') - .description('Edit a resource in your default editor (server, profile, project)') - .argument('', 'Resource type (server, profile, project)') + .description('Edit a resource in your default editor (server, project)') + .argument('', 'Resource type (server, project)') .argument('', 'Resource name or ID') .action(async (resourceArg: string, nameOrId: string) => { const resource = resolveResource(resourceArg); @@ -47,7 +47,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'profiles', 'projects']; + const validResources = ['servers', 'secrets', 'projects']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index e6754f3..af9ce7d 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -17,12 +17,6 @@ interface ServerRow { dockerImage: string | null; } -interface ProfileRow { - id: string; - name: string; - serverId: string; -} - interface ProjectRow { id: string; name: string; @@ -30,6 +24,12 @@ interface ProjectRow { ownerId: string; } +interface SecretRow { + id: string; + name: string; + data: Record; +} + interface InstanceRow { id: string; serverId: string; @@ -46,12 +46,6 @@ const serverColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; -const profileColumns: Column[] = [ - { header: 'NAME', key: 'name' }, - { header: 'SERVER ID', key: 'serverId' }, - { header: 'ID', key: 'id' }, -]; - const projectColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'DESCRIPTION', key: 'description', width: 40 }, @@ -59,6 +53,12 @@ const projectColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +const secretColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 }, + { header: 'ID', key: 'id' }, +]; + const instanceColumns: Column[] = [ { header: 'STATUS', key: 'status', width: 10 }, { header: 'SERVER ID', key: 'serverId' }, @@ -71,10 +71,10 @@ function getColumnsForResource(resource: string): Column switch (resource) { case 'servers': return serverColumns as unknown as Column>[]; - case 'profiles': - return profileColumns as unknown as Column>[]; case 'projects': return projectColumns as unknown as Column>[]; + case 'secrets': + return secretColumns as unknown as Column>[]; case 'instances': return instanceColumns as unknown as Column>[]; default: @@ -91,21 +91,15 @@ function getColumnsForResource(resource: string): Column */ function toApplyFormat(resource: string, items: unknown[]): Record { const cleaned = items.map((item) => { - const obj = stripInternalFields(item as Record); - // For profiles: convert serverId → server (name) for apply compat - // We can't resolve the name here without an API call, so keep serverId - // but also remove it's not in the apply schema. Actually profiles use - // "server" (name) in apply format but serverId from API. Keep serverId - // since it can still be used with apply (the apply command resolves names). - return obj; + return stripInternalFields(item as Record); }); return { [resource]: cleaned }; } export function createGetCommand(deps: GetCommandDeps): Command { return new Command('get') - .description('List resources (servers, profiles, projects, instances)') - .argument('', 'resource type (servers, profiles, projects, instances)') + .description('List resources (servers, projects, instances)') + .argument('', 'resource type (servers, projects, instances)') .argument('[id]', 'specific resource ID or name') .option('-o, --output ', 'output format (table, json, yaml)', 'table') .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { diff --git a/src/cli/src/commands/project.ts b/src/cli/src/commands/project.ts index d333735..a4a1249 100644 --- a/src/cli/src/commands/project.ts +++ b/src/cli/src/commands/project.ts @@ -1,52 +1,15 @@ import { Command } from 'commander'; import type { ApiClient } from '../api-client.js'; -interface Profile { - id: string; - name: string; - serverId: string; -} - export interface ProjectCommandDeps { client: ApiClient; log: (...args: unknown[]) => void; } export function createProjectCommand(deps: ProjectCommandDeps): Command { - const { client, log } = deps; - const cmd = new Command('project') .alias('proj') .description('Project-specific actions (create with "create project", list with "get projects")'); - cmd - .command('profiles ') - .description('List profiles assigned to a project') - .option('-o, --output ', 'Output format (table, json)', 'table') - .action(async (id: string, opts: { output: string }) => { - const profiles = await client.get(`/api/v1/projects/${id}/profiles`); - if (opts.output === 'json') { - log(JSON.stringify(profiles, null, 2)); - return; - } - if (profiles.length === 0) { - log('No profiles assigned.'); - return; - } - log('ID\tNAME\tSERVER'); - for (const p of profiles) { - log(`${p.id}\t${p.name}\t${p.serverId}`); - } - }); - - cmd - .command('set-profiles ') - .description('Set the profiles assigned to a project') - .argument('', 'Profile IDs to assign') - .action(async (id: string, profileIds: string[]) => { - await client.put(`/api/v1/projects/${id}/profiles`, { profileIds }); - log(`Set ${profileIds.length} profile(s) for project '${id}'.`); - }); - return cmd; } diff --git a/src/cli/src/commands/setup.ts b/src/cli/src/commands/setup.ts deleted file mode 100644 index fc8b91c..0000000 --- a/src/cli/src/commands/setup.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { Command } from 'commander'; -import type { ApiClient } from '../api-client.js'; - -export interface SetupPromptDeps { - input: (message: string) => Promise; - password: (message: string) => Promise; - select: (message: string, choices: Array<{ name: string; value: T }>) => Promise; - confirm: (message: string) => Promise; -} - -export interface SetupCommandDeps { - client: ApiClient; - prompt: SetupPromptDeps; - log: (...args: unknown[]) => void; -} - -export function createSetupCommand(deps: SetupCommandDeps): Command { - const { client, prompt, log } = deps; - - return new Command('setup') - .description('Interactive wizard for configuring an MCP server') - .argument('[server-name]', 'Server name to set up (will prompt if not given)') - .action(async (serverName?: string) => { - log('MCP Server Setup Wizard\n'); - - // Step 1: Server name - const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):'); - if (!name) { - log('Setup cancelled.'); - return; - } - - // Step 2: Transport - const transport = await prompt.select('Transport type:', [ - { name: 'STDIO (command-line process)', value: 'STDIO' as const }, - { name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const }, - { name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const }, - ]); - - // Step 3: Package or image - const packageName = await prompt.input('NPM package name (or leave empty):'); - const dockerImage = await prompt.input('Docker image (or leave empty):'); - - // Step 4: Description - const description = await prompt.input('Description:'); - - // Step 5: Create the server - const serverData: Record = { - name, - transport, - description, - }; - if (packageName) serverData.packageName = packageName; - if (dockerImage) serverData.dockerImage = dockerImage; - - let server: { id: string; name: string }; - try { - server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData); - log(`\nServer '${server.name}' created.`); - } catch (err) { - log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`); - return; - } - - // Step 6: Create a profile with env vars - const createProfile = await prompt.confirm('Create a profile with environment variables?'); - if (!createProfile) { - log('\nSetup complete!'); - return; - } - - const profileName = await prompt.input('Profile name:') || 'default'; - - // Collect env vars - const envOverrides: Record = {}; - let addMore = true; - while (addMore) { - const envName = await prompt.input('Environment variable name (empty to finish):'); - if (!envName) break; - - const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`); - const envValue = isSecret - ? await prompt.password(`Value for ${envName}:`) - : await prompt.input(`Value for ${envName}:`); - - envOverrides[envName] = envValue; - addMore = await prompt.confirm('Add another environment variable?'); - } - - try { - await client.post('/api/v1/profiles', { - name: profileName, - serverId: server.id, - envOverrides, - }); - log(`Profile '${profileName}' created for server '${name}'.`); - } catch (err) { - log(`Failed to create profile: ${err instanceof Error ? err.message : err}`); - } - - log('\nSetup complete!'); - }); -} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index ed73589..1efcab9 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -3,12 +3,12 @@ import type { ApiClient } from '../api-client.js'; export const RESOURCE_ALIASES: Record = { server: 'servers', srv: 'servers', - profile: 'profiles', - prof: 'profiles', project: 'projects', proj: 'projects', instance: 'instances', inst: 'instances', + secret: 'secrets', + sec: 'secrets', }; export function resolveResource(name: string): string { diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 852a3cf..5c83977 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -10,7 +10,6 @@ import { createLogsCommand } from './commands/logs.js'; import { createApplyCommand } from './commands/apply.js'; import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; -import { createSetupCommand } from './commands/setup.js'; import { createClaudeCommand } from './commands/claude.js'; import { createProjectCommand } from './commands/project.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; @@ -110,33 +109,6 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); - program.addCommand(createSetupCommand({ - client, - prompt: { - async input(message) { - const { default: inquirer } = await import('inquirer'); - const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]); - return answer as string; - }, - async password(message) { - const { default: inquirer } = await import('inquirer'); - const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]); - return answer as string; - }, - async select(message, choices) { - const { default: inquirer } = await import('inquirer'); - const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]); - return answer; - }, - async confirm(message) { - const { default: inquirer } = await import('inquirer'); - const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]); - return answer as boolean; - }, - }, - log: (...args) => console.log(...args), - })); - program.addCommand(createClaudeCommand({ client, log: (...args) => console.log(...args), diff --git a/src/cli/tests/commands/apply.test.ts b/src/cli/tests/commands/apply.test.ts index de1cc0f..6c73e9e 100644 --- a/src/cli/tests/commands/apply.test.ts +++ b/src/cli/tests/commands/apply.test.ts @@ -86,9 +86,6 @@ servers: servers: - name: test transport: STDIO -profiles: - - name: default - server: test `); const cmd = createApplyCommand({ client, log }); @@ -97,52 +94,51 @@ profiles: expect(client.post).not.toHaveBeenCalled(); expect(output.join('\n')).toContain('Dry run'); expect(output.join('\n')).toContain('1 server(s)'); - expect(output.join('\n')).toContain('1 profile(s)'); rmSync(tmpDir, { recursive: true, force: true }); }); - it('applies profiles with server lookup', async () => { + it('applies secrets', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +secrets: + - name: ha-creds + data: + TOKEN: abc123 + URL: https://ha.local +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({ + name: 'ha-creds', + data: { TOKEN: 'abc123', URL: 'https://ha.local' }, + })); + expect(output.join('\n')).toContain('Created secret: ha-creds'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates existing secrets', async () => { vi.mocked(client.get).mockImplementation(async (url: string) => { - if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }]; + if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }]; return []; }); const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` -profiles: - - name: default - server: slack - envOverrides: - SLACK_TOKEN: "xoxb-test" +secrets: + - name: ha-creds + data: + TOKEN: new-token `); const cmd = createApplyCommand({ client, log }); await cmd.parseAsync([configPath], { from: 'user' }); - expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ - name: 'default', - serverId: 'srv-1', - envOverrides: { SLACK_TOKEN: 'xoxb-test' }, - })); - expect(output.join('\n')).toContain('Created profile: default'); - - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('skips profiles when server not found', async () => { - const configPath = join(tmpDir, 'config.yaml'); - writeFileSync(configPath, ` -profiles: - - name: default - server: nonexistent -`); - - const cmd = createApplyCommand({ client, log }); - await cmd.parseAsync([configPath], { from: 'user' }); - - expect(client.post).not.toHaveBeenCalled(); - expect(output.join('\n')).toContain("Skipping profile 'default'"); + expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } }); + expect(output.join('\n')).toContain('Updated secret: ha-creds'); rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts index 20b9b6f..ad214f2 100644 --- a/src/cli/tests/commands/create.test.ts +++ b/src/cli/tests/commands/create.test.ts @@ -46,8 +46,8 @@ describe('create command', () => { '--command', 'python', '--command', '-c', '--command', 'print("hello")', - '--env-template', 'API_KEY:API key:true', - '--env-template', 'BASE_URL:Base URL:false', + '--env', 'API_KEY=secretRef:creds:API_KEY', + '--env', 'BASE_URL=http://localhost', ], { from: 'user' }); expect(client.post).toHaveBeenCalledWith('/api/v1/servers', { @@ -59,9 +59,9 @@ describe('create command', () => { containerPort: 3000, replicas: 2, command: ['python', '-c', 'print("hello")'], - envTemplate: [ - { name: 'API_KEY', description: 'API key', isSecret: true }, - { name: 'BASE_URL', description: 'Base URL', isSecret: false }, + env: [ + { name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } }, + { name: 'BASE_URL', value: 'http://localhost' }, ], }); }); @@ -75,49 +75,28 @@ describe('create command', () => { }); }); - describe('create profile', () => { - it('creates a profile resolving server name', async () => { - vi.mocked(client.get).mockResolvedValue([ - { id: 'srv-abc', name: 'ha-mcp' }, - ]); - const cmd = createCreateCommand({ client, log }); - await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' }); - expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ - name: 'production', - serverId: 'srv-abc', - })); - }); - - it('parses --env KEY=value entries', async () => { - vi.mocked(client.get).mockResolvedValue([ - { id: 'srv-1', name: 'test' }, - ]); + describe('create secret', () => { + it('creates a secret with --data flags', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync([ - 'profile', 'dev', - '--server', 'test', - '--env', 'FOO=bar', - '--env', 'SECRET=s3cr3t', + 'secret', 'ha-creds', + '--data', 'TOKEN=abc123', + '--data', 'URL=https://ha.local', ], { from: 'user' }); - expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ - envOverrides: { FOO: 'bar', SECRET: 's3cr3t' }, - })); + expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', { + name: 'ha-creds', + data: { TOKEN: 'abc123', URL: 'https://ha.local' }, + }); + expect(output.join('\n')).toContain("secret 'test' created"); }); - it('passes permissions', async () => { - vi.mocked(client.get).mockResolvedValue([ - { id: 'srv-1', name: 'test' }, - ]); + it('creates a secret with empty data', async () => { const cmd = createCreateCommand({ client, log }); - await cmd.parseAsync([ - 'profile', 'admin', - '--server', 'test', - '--permissions', 'read', - '--permissions', 'write', - ], { from: 'user' }); - expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ - permissions: ['read', 'write'], - })); + await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', { + name: 'empty-secret', + data: {}, + }); }); }); diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts index d4adf0a..013887f 100644 --- a/src/cli/tests/commands/describe.test.ts +++ b/src/cli/tests/commands/describe.test.ts @@ -30,7 +30,7 @@ describe('describe command', () => { transport: 'STDIO', packageName: '@slack/mcp', dockerImage: null, - envTemplate: [], + env: [], createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); @@ -50,10 +50,10 @@ describe('describe command', () => { }); it('resolves resource aliases', async () => { - const deps = makeDeps({ id: 'p1' }); + const deps = makeDeps({ id: 's1' }); const cmd = createDescribeCommand(deps); - await cmd.parseAsync(['node', 'test', 'prof', 'p1']); - expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1'); + await cmd.parseAsync(['node', 'test', 'sec', 's1']); + expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1'); }); it('outputs JSON format', async () => { @@ -72,26 +72,6 @@ describe('describe command', () => { expect(deps.output[0]).toContain('name: slack'); }); - it('shows profile with permissions and env overrides', async () => { - const deps = makeDeps({ - id: 'p1', - name: 'production', - serverId: 'srv-1', - permissions: ['read', 'write'], - envOverrides: { FOO: 'bar', SECRET: 's3cr3t' }, - createdAt: '2025-01-01', - }); - const cmd = createDescribeCommand(deps); - await cmd.parseAsync(['node', 'test', 'profile', 'p1']); - - const text = deps.output.join('\n'); - expect(text).toContain('=== Profile: production ==='); - expect(text).toContain('read, write'); - expect(text).toContain('Environment Overrides:'); - expect(text).toContain('FOO'); - expect(text).toContain('bar'); - }); - it('shows project detail', async () => { const deps = makeDeps({ id: 'proj-1', @@ -109,6 +89,39 @@ describe('describe command', () => { expect(text).toContain('user-1'); }); + it('shows secret detail with masked values', async () => { + const deps = makeDeps({ + id: 'sec-1', + name: 'ha-creds', + data: { TOKEN: 'abc123', URL: 'https://ha.local' }, + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Secret: ha-creds ==='); + expect(text).toContain('TOKEN'); + expect(text).toContain('***'); + expect(text).not.toContain('abc123'); + expect(text).toContain('use --show-values to reveal'); + }); + + it('shows secret detail with revealed values when --show-values', async () => { + const deps = makeDeps({ + id: 'sec-1', + name: 'ha-creds', + data: { TOKEN: 'abc123' }, + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']); + + const text = deps.output.join('\n'); + expect(text).toContain('abc123'); + expect(text).not.toContain('***'); + }); + it('shows instance detail with container info', async () => { const deps = makeDeps({ id: 'inst-1', diff --git a/src/cli/tests/commands/edit.test.ts b/src/cli/tests/commands/edit.test.ts index 8fb1f45..b0d97bb 100644 --- a/src/cli/tests/commands/edit.test.ts +++ b/src/cli/tests/commands/edit.test.ts @@ -150,31 +150,4 @@ describe('edit command', () => { expect(output.join('\n')).toContain('immutable'); }); - it('edits a profile', async () => { - vi.mocked(client.get).mockImplementation(async (path: string) => { - if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }]; - return { - id: 'prof-1', name: 'production', serverId: 'srv-1', - permissions: ['read'], envOverrides: { FOO: 'bar' }, - createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, - }; - }); - - const cmd = createEditCommand({ - client, - log, - getEditor: () => 'vi', - openEditor: (filePath) => { - const content = readFileSync(filePath, 'utf-8'); - const modified = content.replace('FOO: bar', 'FOO: baz'); - writeFileSync(filePath, modified, 'utf-8'); - }, - }); - - await cmd.parseAsync(['profile', 'production'], { from: 'user' }); - - expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({ - envOverrides: { FOO: 'baz' }, - })); - }); }); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index 2a4f46b..793d712 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -67,16 +67,6 @@ describe('get command', () => { expect(text).not.toContain('createdAt:'); }); - it('lists profiles with correct columns', async () => { - const deps = makeDeps([ - { id: 'p1', name: 'default', serverId: 'srv-1' }, - ]); - const cmd = createGetCommand(deps); - await cmd.parseAsync(['node', 'test', 'profiles']); - expect(deps.output[0]).toContain('NAME'); - expect(deps.output[0]).toContain('SERVER ID'); - }); - it('lists instances with correct columns', async () => { const deps = makeDeps([ { id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 }, diff --git a/src/cli/tests/commands/instances.test.ts b/src/cli/tests/commands/instances.test.ts index 8080119..ba36851 100644 --- a/src/cli/tests/commands/instances.test.ts +++ b/src/cli/tests/commands/instances.test.ts @@ -45,12 +45,6 @@ describe('delete command', () => { expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc'); }); - it('deletes a profile', async () => { - const cmd = createDeleteCommand({ client, log }); - await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' }); - expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1'); - }); - it('deletes a project', async () => { const cmd = createDeleteCommand({ client, log }); await cmd.parseAsync(['project', 'proj-1'], { from: 'user' }); diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index ed2e0cf..911a1b6 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -21,32 +21,9 @@ describe('project command', () => { output = []; }); - describe('profiles', () => { - it('lists profiles for a project', async () => { - vi.mocked(client.get).mockResolvedValue([ - { id: 'prof-1', name: 'default', serverId: 'srv-1' }, - ]); - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); - expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles'); - expect(output.join('\n')).toContain('default'); - }); - - it('shows empty message when no profiles', async () => { - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); - expect(output.join('\n')).toContain('No profiles assigned'); - }); - }); - - describe('set-profiles', () => { - it('sets profiles for a project', async () => { - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' }); - expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', { - profileIds: ['prof-1', 'prof-2'], - }); - expect(output.join('\n')).toContain('2 profile(s)'); - }); + it('creates command with alias', () => { + const cmd = createProjectCommand({ client, log }); + expect(cmd.name()).toBe('project'); + expect(cmd.alias()).toBe('proj'); }); }); diff --git a/src/cli/tests/commands/setup.test.ts b/src/cli/tests/commands/setup.test.ts deleted file mode 100644 index 2c7c15e..0000000 --- a/src/cli/tests/commands/setup.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createSetupCommand } from '../../src/commands/setup.js'; -import type { ApiClient } from '../../src/api-client.js'; -import type { SetupPromptDeps } from '../../src/commands/setup.js'; - -function mockClient(): ApiClient { - return { - get: vi.fn(async () => []), - post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), - put: vi.fn(async () => ({})), - delete: vi.fn(async () => {}), - } as unknown as ApiClient; -} - -function mockPrompt(answers: Record): SetupPromptDeps { - const answersQueue = { ...answers }; - return { - input: vi.fn(async (message: string) => { - for (const [key, val] of Object.entries(answersQueue)) { - if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') { - delete answersQueue[key]; - return val; - } - } - return ''; - }), - password: vi.fn(async () => 'secret-value'), - select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'], - confirm: vi.fn(async (message: string) => { - if (message.includes('profile')) return true; - if (message.includes('secret')) return false; - if (message.includes('another')) return false; - return false; - }), - }; -} - -describe('setup command', () => { - let client: ReturnType; - let output: string[]; - const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); - - beforeEach(() => { - client = mockClient(); - output = []; - }); - - it('creates server with prompted values', async () => { - const prompt = mockPrompt({ - 'transport': 'STDIO', - 'npm package': '@anthropic/slack-mcp', - 'docker image': '', - 'description': 'Slack server', - 'profile name': 'default', - 'environment variable name': '', - }); - - const cmd = createSetupCommand({ client, prompt, log }); - await cmd.parseAsync(['slack'], { from: 'user' }); - - expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ - name: 'slack', - transport: 'STDIO', - })); - expect(output.join('\n')).toContain("Server 'test' created"); - }); - - it('creates profile with env vars', async () => { - vi.mocked(client.post) - .mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create - .mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create - - const prompt = mockPrompt({ - 'transport': 'STDIO', - 'npm package': '', - 'docker image': '', - 'description': '', - 'profile name': 'default', - }); - // Override confirm to create profile and add one env var - let confirmCallCount = 0; - vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => { - confirmCallCount++; - if (msg.includes('profile')) return true; - if (msg.includes('secret')) return true; - if (msg.includes('another')) return false; - return false; - }); - // Override input to provide env var name then empty to stop - let inputCallCount = 0; - vi.mocked(prompt.input).mockImplementation(async (msg: string) => { - inputCallCount++; - if (msg.includes('Profile name')) return 'default'; - if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY'; - if (msg.includes('variable name')) return ''; - return ''; - }); - - const cmd = createSetupCommand({ client, prompt, log }); - await cmd.parseAsync(['slack'], { from: 'user' }); - - expect(client.post).toHaveBeenCalledTimes(2); - const profileCall = vi.mocked(client.post).mock.calls[1]; - expect(profileCall?.[0]).toBe('/api/v1/profiles'); - expect(profileCall?.[1]).toEqual(expect.objectContaining({ - name: 'default', - serverId: 'srv-1', - })); - }); - - it('exits if server creation fails', async () => { - vi.mocked(client.post).mockRejectedValue(new Error('conflict')); - - const prompt = mockPrompt({ - 'npm package': '', - 'docker image': '', - 'description': '', - }); - - const cmd = createSetupCommand({ client, prompt, log }); - await cmd.parseAsync(['slack'], { from: 'user' }); - - expect(output.join('\n')).toContain('Failed to create server'); - expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile - }); - - it('skips profile creation when declined', async () => { - const prompt = mockPrompt({ - 'npm package': '', - 'docker image': '', - 'description': '', - }); - vi.mocked(prompt.confirm).mockResolvedValue(false); - - const cmd = createSetupCommand({ client, prompt, log }); - await cmd.parseAsync(['test-server'], { from: 'user' }); - - expect(client.post).toHaveBeenCalledTimes(1); // Only server create - expect(output.join('\n')).toContain('Setup complete'); - }); -}); diff --git a/src/cli/tests/e2e/cli-commands.test.ts b/src/cli/tests/e2e/cli-commands.test.ts index bdc348e..8d08c40 100644 --- a/src/cli/tests/e2e/cli-commands.test.ts +++ b/src/cli/tests/e2e/cli-commands.test.ts @@ -21,7 +21,6 @@ describe('CLI command registration (e2e)', () => { expect(commandNames).toContain('apply'); expect(commandNames).toContain('create'); expect(commandNames).toContain('edit'); - expect(commandNames).toContain('setup'); expect(commandNames).toContain('claude'); expect(commandNames).toContain('project'); expect(commandNames).toContain('backup'); @@ -46,19 +45,11 @@ describe('CLI command registration (e2e)', () => { expect(subcommands).toContain('remove'); }); - it('project command has action subcommands only', () => { + it('project command exists with alias', () => { const program = createProgram(); const project = program.commands.find((c) => c.name() === 'project'); expect(project).toBeDefined(); - - const subcommands = project!.commands.map((c) => c.name()); - expect(subcommands).toContain('profiles'); - expect(subcommands).toContain('set-profiles'); - // create is now top-level (mcpctl create project) - expect(subcommands).not.toContain('create'); - expect(subcommands).not.toContain('list'); - expect(subcommands).not.toContain('show'); - expect(subcommands).not.toContain('delete'); + expect(project!.alias()).toBe('proj'); }); it('displays version', () => { diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 2619340..6f64821 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -61,12 +61,11 @@ model McpServer { command Json? containerPort Int? replicas Int @default(1) - envTemplate Json @default("[]") + env Json @default("[]") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - profiles McpProfile[] instances McpInstance[] @@index([name]) @@ -78,23 +77,17 @@ enum Transport { STREAMABLE_HTTP } -// ── MCP Profiles ── +// ── Secrets ── -model McpProfile { - id String @id @default(cuid()) - name String - serverId String - permissions Json @default("[]") - envOverrides Json @default("{}") - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Secret { + id String @id @default(cuid()) + name String @unique + data Json @default("{}") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) - projects ProjectMcpProfile[] - - @@unique([name, serverId]) - @@index([serverId]) + @@index([name]) } // ── Projects ── @@ -109,27 +102,11 @@ model Project { updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - profiles ProjectMcpProfile[] @@index([name]) @@index([ownerId]) } -// ── Project <-> Profile join table ── - -model ProjectMcpProfile { - id String @id @default(cuid()) - projectId String - profileId String - - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) - - @@unique([projectId, profileId]) - @@index([projectId]) - @@index([profileId]) -} - // ── MCP Instances (running containers) ── model McpInstance { diff --git a/src/db/src/index.ts b/src/db/src/index.ts index 2312a36..796e9e3 100644 --- a/src/db/src/index.ts +++ b/src/db/src/index.ts @@ -4,9 +4,8 @@ export type { User, Session, McpServer, - McpProfile, + Secret, Project, - ProjectMcpProfile, McpInstance, AuditLog, Role, diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index 8840535..41d4289 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -6,11 +6,10 @@ export interface SeedServer { packageName: string; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; repositoryUrl: string; - envTemplate: Array<{ + env: Array<{ name: string; - description: string; - isSecret: boolean; - setupUrl?: string; + value?: string; + valueFrom?: { secretRef: { name: string; key: string } }; }>; } @@ -21,19 +20,7 @@ export const defaultServers: SeedServer[] = [ packageName: '@anthropic/slack-mcp', transport: 'STDIO', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', - envTemplate: [ - { - name: 'SLACK_BOT_TOKEN', - description: 'Slack Bot User OAuth Token (xoxb-...)', - isSecret: true, - setupUrl: 'https://api.slack.com/apps', - }, - { - name: 'SLACK_TEAM_ID', - description: 'Slack Workspace Team ID', - isSecret: false, - }, - ], + env: [], }, { name: 'jira', @@ -41,24 +28,7 @@ export const defaultServers: SeedServer[] = [ packageName: '@anthropic/jira-mcp', transport: 'STDIO', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira', - envTemplate: [ - { - name: 'JIRA_URL', - description: 'Jira instance URL (e.g., https://company.atlassian.net)', - isSecret: false, - }, - { - name: 'JIRA_EMAIL', - description: 'Jira account email', - isSecret: false, - }, - { - name: 'JIRA_API_TOKEN', - description: 'Jira API token', - isSecret: true, - setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens', - }, - ], + env: [], }, { name: 'github', @@ -66,14 +36,7 @@ export const defaultServers: SeedServer[] = [ packageName: '@anthropic/github-mcp', transport: 'STDIO', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', - envTemplate: [ - { - name: 'GITHUB_TOKEN', - description: 'GitHub Personal Access Token', - isSecret: true, - setupUrl: 'https://github.com/settings/tokens', - }, - ], + env: [], }, { name: 'terraform', @@ -81,7 +44,7 @@ export const defaultServers: SeedServer[] = [ packageName: '@anthropic/terraform-mcp', transport: 'STDIO', repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform', - envTemplate: [], + env: [], }, ]; @@ -99,7 +62,7 @@ export async function seedMcpServers( packageName: server.packageName, transport: server.transport, repositoryUrl: server.repositoryUrl, - envTemplate: server.envTemplate, + env: server.env, }, create: { name: server.name, @@ -107,7 +70,7 @@ export async function seedMcpServers( packageName: server.packageName, transport: server.transport, repositoryUrl: server.repositoryUrl, - envTemplate: server.envTemplate, + env: server.env, }, }); created++; diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 7ef2c06..ae3a5b3 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -48,9 +48,8 @@ export async function cleanupTestDb(): Promise { export async function clearAllTables(client: PrismaClient): Promise { // Delete in order respecting foreign keys await client.auditLog.deleteMany(); - await client.projectMcpProfile.deleteMany(); await client.mcpInstance.deleteMany(); - await client.mcpProfile.deleteMany(); + await client.secret.deleteMany(); await client.session.deleteMany(); await client.project.deleteMany(); await client.mcpServer.deleteMany(); diff --git a/src/db/tests/models.test.ts b/src/db/tests/models.test.ts index 7e01abf..8d22b54 100644 --- a/src/db/tests/models.test.ts +++ b/src/db/tests/models.test.ts @@ -123,7 +123,7 @@ describe('McpServer', () => { const server = await createServer(); expect(server.transport).toBe('STDIO'); expect(server.version).toBe(1); - expect(server.envTemplate).toEqual([]); + expect(server.env).toEqual([]); }); it('enforces unique name', async () => { @@ -131,18 +131,18 @@ describe('McpServer', () => { await expect(createServer({ name: 'slack' })).rejects.toThrow(); }); - it('stores envTemplate as JSON', async () => { + it('stores env as JSON', async () => { const server = await prisma.mcpServer.create({ data: { name: 'with-env', - envTemplate: [ - { name: 'API_KEY', description: 'Key', isSecret: true }, + env: [ + { name: 'API_KEY', value: 'test-key' }, ], }, }); - const envTemplate = server.envTemplate as Array<{ name: string }>; - expect(envTemplate).toHaveLength(1); - expect(envTemplate[0].name).toBe('API_KEY'); + const env = server.env as Array<{ name: string }>; + expect(env).toHaveLength(1); + expect(env[0].name).toBe('API_KEY'); }); it('supports SSE transport', async () => { @@ -151,43 +151,46 @@ describe('McpServer', () => { }); }); -// ── McpProfile model ── +// ── Secret model ── -describe('McpProfile', () => { - it('creates a profile linked to server', async () => { - const server = await createServer(); - const profile = await prisma.mcpProfile.create({ +describe('Secret', () => { + it('creates a secret with defaults', async () => { + const secret = await prisma.secret.create({ + data: { name: 'my-secret' }, + }); + expect(secret.name).toBe('my-secret'); + expect(secret.data).toEqual({}); + expect(secret.version).toBe(1); + }); + + it('stores key-value data as JSON', async () => { + const secret = await prisma.secret.create({ data: { - name: 'readonly', - serverId: server.id, - permissions: ['read'], + name: 'api-keys', + data: { API_KEY: 'test-key', API_SECRET: 'test-secret' }, }, }); - expect(profile.name).toBe('readonly'); - expect(profile.serverId).toBe(server.id); + const data = secret.data as Record; + expect(data['API_KEY']).toBe('test-key'); + expect(data['API_SECRET']).toBe('test-secret'); }); - it('enforces unique name per server', async () => { - const server = await createServer(); - const data = { name: 'default', serverId: server.id }; - await prisma.mcpProfile.create({ data }); - await expect(prisma.mcpProfile.create({ data })).rejects.toThrow(); + it('enforces unique name', async () => { + await prisma.secret.create({ data: { name: 'dup-secret' } }); + await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow(); }); - it('allows same profile name on different servers', async () => { - const server1 = await createServer({ name: 'server-1' }); - const server2 = await createServer({ name: 'server-2' }); - await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } }); - const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } }); - expect(profile2.name).toBe('default'); - }); - - it('cascades delete when server is deleted', async () => { - const server = await createServer(); - await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } }); - await prisma.mcpServer.delete({ where: { id: server.id } }); - const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } }); - expect(profiles).toHaveLength(0); + it('updates data', async () => { + const secret = await prisma.secret.create({ + data: { name: 'updatable', data: { KEY: 'old' } }, + }); + const updated = await prisma.secret.update({ + where: { id: secret.id }, + data: { data: { KEY: 'new', EXTRA: 'added' } }, + }); + const data = updated.data as Record; + expect(data['KEY']).toBe('new'); + expect(data['EXTRA']).toBe('added'); }); }); @@ -220,62 +223,6 @@ describe('Project', () => { }); }); -// ── ProjectMcpProfile (join table) ── - -describe('ProjectMcpProfile', () => { - it('links project to profile', async () => { - const user = await createUser(); - const server = await createServer(); - const profile = await prisma.mcpProfile.create({ - data: { name: 'default', serverId: server.id }, - }); - const project = await prisma.project.create({ - data: { name: 'test-project', ownerId: user.id }, - }); - - const link = await prisma.projectMcpProfile.create({ - data: { projectId: project.id, profileId: profile.id }, - }); - expect(link.projectId).toBe(project.id); - expect(link.profileId).toBe(profile.id); - }); - - it('enforces unique project+profile combination', async () => { - const user = await createUser(); - const server = await createServer(); - const profile = await prisma.mcpProfile.create({ - data: { name: 'default', serverId: server.id }, - }); - const project = await prisma.project.create({ - data: { name: 'test-project', ownerId: user.id }, - }); - - const data = { projectId: project.id, profileId: profile.id }; - await prisma.projectMcpProfile.create({ data }); - await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow(); - }); - - it('loads profiles through project include', async () => { - const user = await createUser(); - const server = await createServer(); - const profile = await prisma.mcpProfile.create({ - data: { name: 'slack-ro', serverId: server.id }, - }); - const project = await prisma.project.create({ - data: { name: 'reports', ownerId: user.id }, - }); - await prisma.projectMcpProfile.create({ - data: { projectId: project.id, profileId: profile.id }, - }); - - const loaded = await prisma.project.findUnique({ - where: { id: project.id }, - include: { profiles: { include: { profile: true } } }, - }); - expect(loaded!.profiles).toHaveLength(1); - expect(loaded!.profiles[0].profile.name).toBe('slack-ro'); - }); -}); // ── McpInstance model ── diff --git a/src/db/tests/seed.test.ts b/src/db/tests/seed.test.ts index 41ddbd5..4190d1f 100644 --- a/src/db/tests/seed.test.ts +++ b/src/db/tests/seed.test.ts @@ -41,13 +41,11 @@ describe('seedMcpServers', () => { expect(servers).toHaveLength(defaultServers.length); }); - it('seeds envTemplate correctly', async () => { + it('seeds env correctly', async () => { await seedMcpServers(prisma); const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } }); - const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>; - expect(envTemplate).toHaveLength(2); - expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN'); - expect(envTemplate[0].isSecret).toBe(true); + const env = slack!.env as Array<{ name: string; value?: string }>; + expect(env).toEqual([]); }); it('accepts custom server list', async () => { @@ -58,7 +56,7 @@ describe('seedMcpServers', () => { packageName: '@test/custom', transport: 'STDIO' as const, repositoryUrl: 'https://example.com', - envTemplate: [], + env: [], }, ]; const count = await seedMcpServers(prisma, custom); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 4e23e84..6455557 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { // 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 { // 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 { // Routes registerMcpServerRoutes(app, serverService, instanceService); - registerMcpProfileRoutes(app, profileService); + registerSecretRoutes(app, secretService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); registerAuditLogRoutes(app, auditLogService); diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 98da343..5f72c38 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -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'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 00d2d20..fb79076 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -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; @@ -20,12 +20,12 @@ export interface IMcpInstanceRepository { delete(id: string): Promise; } -export interface IMcpProfileRepository { - findAll(serverId?: string): Promise; - findById(id: string): Promise; - findByServerAndName(serverId: string, name: string): Promise; - create(data: CreateMcpProfileInput): Promise; - update(id: string, data: UpdateMcpProfileInput): Promise; +export interface ISecretRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateSecretInput): Promise; + update(id: string, data: UpdateSecretInput): Promise; delete(id: string): Promise; } diff --git a/src/mcpd/src/repositories/mcp-profile.repository.ts b/src/mcpd/src/repositories/mcp-profile.repository.ts deleted file mode 100644 index 7128091..0000000 --- a/src/mcpd/src/repositories/mcp-profile.repository.ts +++ /dev/null @@ -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 { - const where = serverId !== undefined ? { serverId } : {}; - return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } }); - } - - async findById(id: string): Promise { - return this.prisma.mcpProfile.findUnique({ where: { id } }); - } - - async findByServerAndName(serverId: string, name: string): Promise { - return this.prisma.mcpProfile.findUnique({ - where: { name_serverId: { name, serverId } }, - }); - } - - async create(data: CreateMcpProfileInput): Promise { - 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 { - const updateData: Record = {}; - 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 { - await this.prisma.mcpProfile.delete({ where: { id } }); - } -} diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts index 9c0fceb..e4cd82b 100644 --- a/src/mcpd/src/repositories/mcp-server.repository.ts +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -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 }); } diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts index 8980ddc..97acf10 100644 --- a/src/mcpd/src/repositories/project.repository.ts +++ b/src/mcpd/src/repositories/project.repository.ts @@ -8,8 +8,6 @@ export interface IProjectRepository { create(data: CreateProjectInput & { ownerId: string }): Promise; update(id: string, data: UpdateProjectInput): Promise; delete(id: string): Promise; - setProfiles(projectId: string, profileIds: string[]): Promise; - getProfileIds(projectId: string): Promise; } 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 { - 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 { - const links = await this.prisma.projectMcpProfile.findMany({ - where: { projectId }, - select: { profileId: true }, - }); - return links.map((l) => l.profileId); - } } diff --git a/src/mcpd/src/repositories/secret.repository.ts b/src/mcpd/src/repositories/secret.repository.ts new file mode 100644 index 0000000..05bb162 --- /dev/null +++ b/src/mcpd/src/repositories/secret.repository.ts @@ -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 { + return this.prisma.secret.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.secret.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.secret.findUnique({ where: { name } }); + } + + async create(data: CreateSecretInput): Promise { + return this.prisma.secret.create({ + data: { + name: data.name, + data: data.data, + }, + }); + } + + async update(id: string, data: UpdateSecretInput): Promise { + return this.prisma.secret.update({ + where: { id }, + data: { data: data.data }, + }); + } + + async delete(id: string): Promise { + await this.prisma.secret.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/backup.ts b/src/mcpd/src/routes/backup.ts index 5c5027d..acfcc6a 100644 --- a/src/mcpd/src/routes/backup.ts +++ b/src/mcpd/src/routes/backup.ts @@ -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); } diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 9af5d1a..fe7ba0e 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -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'; diff --git a/src/mcpd/src/routes/mcp-profiles.ts b/src/mcpd/src/routes/mcp-profiles.ts deleted file mode 100644 index c710375..0000000 --- a/src/mcpd/src/routes/mcp-profiles.ts +++ /dev/null @@ -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); - }); -} diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts index 73bc54b..b026219 100644 --- a/src/mcpd/src/routes/projects.ts +++ b/src/mcpd/src/routes/projects.ts @@ -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); - }); } diff --git a/src/mcpd/src/routes/secrets.ts b/src/mcpd/src/routes/secrets.ts new file mode 100644 index 0000000..a1fa9a7 --- /dev/null +++ b/src/mcpd/src/routes/secrets.ts @@ -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); + }); +} diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index 7c70345..c2a97cb 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -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; } 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 { - 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(); - 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, })); } if (resources.includes('projects')) { const allProjects = await this.projectRepo.findAll(); - const allProfiles = await this.profileRepo.findAll(); - const profileMap = new Map(); - 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 = {}; - for (const profile of profiles) { - const overrides = profile.envOverrides as Record | 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)[key] = `__ENCRYPTED:${secretKey}__`; - } + if (options?.password && secrets.length > 0) { + // Collect sensitive values from secrets and encrypt them + const sensitiveData: Record = {}; + 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); } } diff --git a/src/mcpd/src/services/backup/index.ts b/src/mcpd/src/services/backup/index.ts index f70ed2a..e00ba9f 100644 --- a/src/mcpd/src/services/backup/index.ts +++ b/src/mcpd/src/services/backup/index.ts @@ -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'; diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index 2ffceaf..072a353 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -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 = {}; + let decryptedSecrets: Record = {}; 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; + decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record; } 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 | 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(); 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(); - 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, - }); - 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, + 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)}`); diff --git a/src/mcpd/src/services/env-resolver.ts b/src/mcpd/src/services/env-resolver.ts new file mode 100644 index 0000000..efc8bcd --- /dev/null +++ b/src/mcpd/src/services/env-resolver.ts @@ -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> { + const entries = server.env as ServerEnvEntry[]; + if (!entries || entries.length === 0) return {}; + + const result: Record = {}; + const secretCache = new Map>(); + + 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); + } + + 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; +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index ea075cb..34e0f57 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -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'; diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index db7a6ba..ac4689f 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -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 { @@ -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 } = { diff --git a/src/mcpd/src/services/mcp-config-generator.ts b/src/mcpd/src/services/mcp-config-generator.ts index 8be1164..35e1513 100644 --- a/src/mcpd/src/services/mcp-config-generator.ts +++ b/src/mcpd/src/services/mcp-config-generator.ts @@ -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; } -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 }>, +): McpConfig { const mcpServers: Record = {}; - 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; - - // Build env: only include non-secret env vars - const env: Record = {}; - 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 }; diff --git a/src/mcpd/src/services/mcp-profile.service.ts b/src/mcpd/src/services/mcp-profile.service.ts deleted file mode 100644 index c2a33df..0000000 --- a/src/mcpd/src/services/mcp-profile.service.ts +++ /dev/null @@ -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 { - return this.profileRepo.findAll(serverId); - } - - async getById(id: string): Promise { - const profile = await this.profileRepo.findById(id); - if (profile === null) { - throw new NotFoundError(`Profile not found: ${id}`); - } - return profile; - } - - async create(input: unknown): Promise { - 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 { - 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 { - await this.getById(id); - await this.profileRepo.delete(id); - } -} diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index ef763cc..298fbca 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -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 { - 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 { - await this.getById(projectId); - return this.projectRepo.getProfileIds(projectId); - } - - async getMcpConfig(projectId: string): Promise { - 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); - } } diff --git a/src/mcpd/src/services/secret.service.ts b/src/mcpd/src/services/secret.service.ts new file mode 100644 index 0000000..330376f --- /dev/null +++ b/src/mcpd/src/services/secret.service.ts @@ -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 { + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const secret = await this.repo.findById(id); + if (secret === null) { + throw new NotFoundError(`Secret not found: ${id}`); + } + return secret; + } + + async getByName(name: string): Promise { + const secret = await this.repo.findByName(name); + if (secret === null) { + throw new NotFoundError(`Secret not found: ${name}`); + } + return secret; + } + + async create(input: unknown): Promise { + 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 { + const data = UpdateSecretSchema.parse(input); + + // Verify exists + await this.getById(id); + + return this.repo.update(id, data); + } + + async delete(id: string): Promise { + // Verify exists + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/validation/index.ts b/src/mcpd/src/validation/index.ts index a879147..a183575 100644 --- a/src/mcpd/src/validation/index.ts +++ b/src/mcpd/src/validation/index.ts @@ -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'; diff --git a/src/mcpd/src/validation/mcp-profile.schema.ts b/src/mcpd/src/validation/mcp-profile.schema.ts deleted file mode 100644 index 7ea4ce4..0000000 --- a/src/mcpd/src/validation/mcp-profile.schema.ts +++ /dev/null @@ -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; -export type UpdateMcpProfileInput = z.infer; diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts index 4f94633..0865424 100644 --- a/src/mcpd/src/validation/mcp-server.schema.ts +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -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; + 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; diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts index 95ec6a9..355f7d7 100644 --- a/src/mcpd/src/validation/project.schema.ts +++ b/src/mcpd/src/validation/project.schema.ts @@ -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; export type UpdateProjectInput = z.infer; -export type UpdateProjectProfilesInput = z.infer; diff --git a/src/mcpd/src/validation/secret.schema.ts b/src/mcpd/src/validation/secret.schema.ts new file mode 100644 index 0000000..2dadac0 --- /dev/null +++ b/src/mcpd/src/validation/secret.schema.ts @@ -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; +export type UpdateSecretInput = z.infer; diff --git a/src/mcpd/tests/backup.test.ts b/src/mcpd/tests/backup.test.ts index d06734d..9576cb4 100644 --- a/src/mcpd/tests/backup.test.ts +++ b/src/mcpd/tests/backup.test.ts @@ -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; - 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).mockResolvedValue([]); - const emptyProfileRepo = mockProfileRepo(); - (emptyProfileRepo.findAll as ReturnType).mockResolvedValue([]); + const emptySecretRepo = mockSecretRepo(); + (emptySecretRepo.findAll as ReturnType).mockResolvedValue([]); const emptyProjectRepo = mockProjectRepo(); (emptyProjectRepo.findAll as ReturnType).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).mockResolvedValue(null); - (profileRepo.findByServerAndName as ReturnType).mockResolvedValue(null); + (secretRepo.findByName as ReturnType).mockResolvedValue(null); (projectRepo.findByName as ReturnType).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).mockResolvedValue(null); - const rPRepo = mockProfileRepo(); - (rPRepo.findByServerAndName as ReturnType).mockResolvedValue(null); + const rSecRepo = mockSecretRepo(); + (rSecRepo.findByName as ReturnType).mockResolvedValue(null); const rPrRepo = mockProjectRepo(); (rPrRepo.findByName as ReturnType).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(); }); diff --git a/src/mcpd/tests/env-resolver.test.ts b/src/mcpd/tests/env-resolver.test.ts new file mode 100644 index 0000000..a7736f7 --- /dev/null +++ b/src/mcpd/tests/env-resolver.test.ts @@ -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>): 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({}); + }); +}); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts index e1957e2..db4a4d7 100644 --- a/src/mcpd/tests/instance-service.test.ts +++ b/src/mcpd/tests/instance-service.test.ts @@ -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(), diff --git a/src/mcpd/tests/mcp-config-generator.test.ts b/src/mcpd/tests/mcp-config-generator.test.ts index a4817e0..4b3332f 100644 --- a/src/mcpd/tests/mcp-config-generator.test.ts +++ b/src/mcpd/tests/mcp-config-generator.test.ts @@ -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'] { - return { - id: 'p1', - name: 'default', - serverId: 's1', - permissions: [], - envOverrides: {}, - version: 1, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -function makeServer(overrides: Partial = {}): ProfileWithServer['server'] { +function makeServer(overrides: Partial = {}): McpServer { return { id: 's1', name: 'slack', @@ -25,7 +11,7 @@ function makeServer(overrides: Partial = {}): 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 = {}): 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']); }); }); diff --git a/src/mcpd/tests/mcp-profile-service.test.ts b/src/mcpd/tests/mcp-profile-service.test.ts deleted file mode 100644 index ef9a6c5..0000000 --- a/src/mcpd/tests/mcp-profile-service.test.ts +++ /dev/null @@ -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; - let serverRepo: ReturnType; - 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); - }); - }); -}); diff --git a/src/mcpd/tests/mcp-server-flow.test.ts b/src/mcpd/tests/mcp-server-flow.test.ts index 17738b2..5cbc831 100644 --- a/src/mcpd/tests/mcp-server-flow.test.ts +++ b/src/mcpd/tests/mcp-server-flow.test.ts @@ -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' } } }, ], }, }); diff --git a/src/mcpd/tests/mcp-server-routes.test.ts b/src/mcpd/tests/mcp-server-routes.test.ts index d191cb9..987e5ec 100644 --- a/src/mcpd/tests/mcp-server-routes.test.ts +++ b/src/mcpd/tests/mcp-server-routes.test.ts @@ -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(), diff --git a/src/mcpd/tests/mcp-server-service.test.ts b/src/mcpd/tests/mcp-server-service.test.ts index 5e52328..3fe7ebe 100644 --- a/src/mcpd/tests/mcp-server-service.test.ts +++ b/src/mcpd/tests/mcp-server-service.test.ts @@ -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(), diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts index fc75433..b2870f7 100644 --- a/src/mcpd/tests/project-service.test.ts +++ b/src/mcpd/tests/project-service.test.ts @@ -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; - let profileRepo: ReturnType; let serverRepo: ReturnType; 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); diff --git a/src/mcpd/tests/secret-routes.test.ts b/src/mcpd/tests/secret-routes.test.ts new file mode 100644 index 0000000..dca335f --- /dev/null +++ b/src/mcpd/tests/secret-routes.test.ts @@ -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 | 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>(); + 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); + }); + }); +}); diff --git a/src/mcpd/tests/validation.test.ts b/src/mcpd/tests/validation.test.ts index 1529ebc..c7acf13 100644 --- a/src/mcpd/tests/validation.test.ts +++ b/src/mcpd/tests/validation.test.ts @@ -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(); - }); -}); diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 9a3a1da..9f512e7 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -2,4 +2,3 @@ export * from './types/index.js'; export * from './validation/index.js'; export * from './constants/index.js'; export * from './utils/index.js'; -export * from './profiles/index.js'; diff --git a/src/shared/src/profiles/index.ts b/src/shared/src/profiles/index.ts deleted file mode 100644 index 76717f1..0000000 --- a/src/shared/src/profiles/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { ProfileTemplate, ProfileCategory, InstantiatedProfile } from './types.js'; -export { profileTemplateSchema, envTemplateEntrySchema } from './types.js'; -export { ProfileRegistry, defaultRegistry } from './registry.js'; -export { validateTemplate, getMissingEnvVars, instantiateProfile, generateMcpJsonEntry } from './utils.js'; -export * from './templates/index.js'; diff --git a/src/shared/src/profiles/registry.ts b/src/shared/src/profiles/registry.ts deleted file mode 100644 index 22d7df7..0000000 --- a/src/shared/src/profiles/registry.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ProfileTemplate, ProfileCategory } from './types.js'; -import { filesystemTemplate } from './templates/filesystem.js'; -import { githubTemplate } from './templates/github.js'; -import { postgresTemplate } from './templates/postgres.js'; -import { slackTemplate } from './templates/slack.js'; -import { memoryTemplate } from './templates/memory.js'; -import { fetchTemplate } from './templates/fetch.js'; - -const builtinTemplates: ProfileTemplate[] = [ - filesystemTemplate, - githubTemplate, - postgresTemplate, - slackTemplate, - memoryTemplate, - fetchTemplate, -]; - -export class ProfileRegistry { - private templates = new Map(); - - constructor(templates: ProfileTemplate[] = builtinTemplates) { - for (const t of templates) { - this.templates.set(t.id, t); - } - } - - getAll(): ProfileTemplate[] { - return [...this.templates.values()]; - } - - getById(id: string): ProfileTemplate | undefined { - return this.templates.get(id); - } - - getByCategory(category: ProfileCategory): ProfileTemplate[] { - return this.getAll().filter((t) => t.category === category); - } - - getCategories(): ProfileCategory[] { - const cats = new Set(); - for (const t of this.templates.values()) { - cats.add(t.category); - } - return [...cats]; - } - - search(query: string): ProfileTemplate[] { - const q = query.toLowerCase(); - return this.getAll().filter( - (t) => - t.id.includes(q) || - t.name.includes(q) || - t.displayName.toLowerCase().includes(q) || - t.description.toLowerCase().includes(q), - ); - } - - register(template: ProfileTemplate): void { - this.templates.set(template.id, template); - } - - has(id: string): boolean { - return this.templates.has(id); - } -} - -export const defaultRegistry = new ProfileRegistry(); diff --git a/src/shared/src/profiles/templates/fetch.ts b/src/shared/src/profiles/templates/fetch.ts deleted file mode 100644 index 06dd672..0000000 --- a/src/shared/src/profiles/templates/fetch.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const fetchTemplate: ProfileTemplate = { - id: 'fetch', - name: 'fetch', - displayName: 'Fetch', - description: 'Fetch and convert web pages to markdown for reading and analysis', - category: 'utility', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-fetch'], - requiredEnvVars: [], - optionalEnvVars: [], - setupInstructions: 'No configuration required. Fetches web content and converts HTML to markdown.', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/fetch', -}; diff --git a/src/shared/src/profiles/templates/filesystem.ts b/src/shared/src/profiles/templates/filesystem.ts deleted file mode 100644 index 1239ca7..0000000 --- a/src/shared/src/profiles/templates/filesystem.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const filesystemTemplate: ProfileTemplate = { - id: 'filesystem', - name: 'filesystem', - displayName: 'Filesystem', - description: 'Provides read/write access to local filesystem directories', - category: 'filesystem', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem'], - requiredEnvVars: [], - optionalEnvVars: [], - setupInstructions: - 'Append allowed directory paths as additional args. Example: npx -y @modelcontextprotocol/server-filesystem /home/user/docs', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem', -}; diff --git a/src/shared/src/profiles/templates/github.ts b/src/shared/src/profiles/templates/github.ts deleted file mode 100644 index e591379..0000000 --- a/src/shared/src/profiles/templates/github.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const githubTemplate: ProfileTemplate = { - id: 'github', - name: 'github', - displayName: 'GitHub', - description: 'Interact with GitHub repositories, issues, pull requests, and more', - category: 'integration', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-github'], - requiredEnvVars: [ - { - name: 'GITHUB_PERSONAL_ACCESS_TOKEN', - description: 'GitHub personal access token with repo scope', - isSecret: true, - setupUrl: 'https://github.com/settings/tokens', - }, - ], - optionalEnvVars: [], - setupInstructions: 'Create a personal access token at GitHub Settings > Developer settings > Personal access tokens.', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', -}; diff --git a/src/shared/src/profiles/templates/index.ts b/src/shared/src/profiles/templates/index.ts deleted file mode 100644 index e952df6..0000000 --- a/src/shared/src/profiles/templates/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { filesystemTemplate } from './filesystem.js'; -export { githubTemplate } from './github.js'; -export { postgresTemplate } from './postgres.js'; -export { slackTemplate } from './slack.js'; -export { memoryTemplate } from './memory.js'; -export { fetchTemplate } from './fetch.js'; diff --git a/src/shared/src/profiles/templates/memory.ts b/src/shared/src/profiles/templates/memory.ts deleted file mode 100644 index 0952ff5..0000000 --- a/src/shared/src/profiles/templates/memory.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const memoryTemplate: ProfileTemplate = { - id: 'memory', - name: 'memory', - displayName: 'Memory', - description: 'Persistent knowledge graph memory for storing and retrieving entities and relations', - category: 'utility', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-memory'], - requiredEnvVars: [], - optionalEnvVars: [], - setupInstructions: 'No configuration required. Memory is stored locally in a JSON knowledge graph file.', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory', -}; diff --git a/src/shared/src/profiles/templates/postgres.ts b/src/shared/src/profiles/templates/postgres.ts deleted file mode 100644 index 09ed2e2..0000000 --- a/src/shared/src/profiles/templates/postgres.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const postgresTemplate: ProfileTemplate = { - id: 'postgres', - name: 'postgres', - displayName: 'PostgreSQL', - description: 'Query and inspect PostgreSQL databases with read-only access', - category: 'database', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-postgres'], - requiredEnvVars: [ - { - name: 'DATABASE_URL', - description: 'PostgreSQL connection string (e.g., postgresql://user:pass@localhost:5432/dbname)', - isSecret: true, - }, - ], - optionalEnvVars: [], - setupInstructions: 'Provide a PostgreSQL connection string. The server provides read-only query access by default.', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres', -}; diff --git a/src/shared/src/profiles/templates/slack.ts b/src/shared/src/profiles/templates/slack.ts deleted file mode 100644 index 6864ea5..0000000 --- a/src/shared/src/profiles/templates/slack.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ProfileTemplate } from '../types.js'; - -export const slackTemplate: ProfileTemplate = { - id: 'slack', - name: 'slack', - displayName: 'Slack', - description: 'Read and send Slack messages, manage channels, and search workspace content', - category: 'integration', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-slack'], - requiredEnvVars: [ - { - name: 'SLACK_BOT_TOKEN', - description: 'Slack Bot User OAuth Token (starts with xoxb-)', - isSecret: true, - setupUrl: 'https://api.slack.com/apps', - }, - { - name: 'SLACK_TEAM_ID', - description: 'Slack workspace/team ID', - isSecret: false, - }, - ], - optionalEnvVars: [], - setupInstructions: - 'Create a Slack App at api.slack.com/apps, install it to your workspace, and copy the Bot User OAuth Token.', - documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', -}; diff --git a/src/shared/src/profiles/types.ts b/src/shared/src/profiles/types.ts deleted file mode 100644 index 5baa69e..0000000 --- a/src/shared/src/profiles/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from 'zod'; - -export const envTemplateEntrySchema = z.object({ - name: z.string().min(1), - description: z.string(), - isSecret: z.boolean(), - setupUrl: z.string().url().optional(), - defaultValue: z.string().optional(), -}); - -export const profileTemplateSchema = z.object({ - id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'ID must be lowercase alphanumeric with hyphens'), - name: z.string().min(1), - displayName: z.string().min(1), - description: z.string().min(1), - category: z.enum(['filesystem', 'database', 'integration', 'ai', 'utility', 'development']), - command: z.string().min(1), - args: z.array(z.string()), - requiredEnvVars: z.array(envTemplateEntrySchema).default([]), - optionalEnvVars: z.array(envTemplateEntrySchema).default([]), - setupInstructions: z.string().optional(), - documentationUrl: z.string().url().optional(), -}); - -export type ProfileTemplate = z.infer; - -export type ProfileCategory = ProfileTemplate['category']; - -export interface InstantiatedProfile { - name: string; - templateId: string; - command: string; - args: string[]; - env: Record; -} diff --git a/src/shared/src/profiles/utils.ts b/src/shared/src/profiles/utils.ts deleted file mode 100644 index f6f39e1..0000000 --- a/src/shared/src/profiles/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { profileTemplateSchema } from './types.js'; -import type { ProfileTemplate, InstantiatedProfile } from './types.js'; - -export function validateTemplate(template: unknown): { success: true; data: ProfileTemplate } | { success: false; errors: string[] } { - const result = profileTemplateSchema.safeParse(template); - if (result.success) { - return { success: true, data: result.data }; - } - return { - success: false, - errors: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), - }; -} - -export function getMissingEnvVars(template: ProfileTemplate, envValues: Record): string[] { - return template.requiredEnvVars - .filter((e) => !envValues[e.name] && e.defaultValue === undefined) - .map((e) => e.name); -} - -export function instantiateProfile( - template: ProfileTemplate, - envValues: Record, -): InstantiatedProfile { - const missing = getMissingEnvVars(template, envValues); - if (missing.length > 0) { - throw new Error(`Missing required environment variables: ${missing.join(', ')}`); - } - - const env: Record = {}; - for (const entry of template.requiredEnvVars) { - const value = envValues[entry.name] ?? entry.defaultValue; - if (value !== undefined) { - env[entry.name] = value; - } - } - for (const entry of template.optionalEnvVars) { - const value = envValues[entry.name] ?? entry.defaultValue; - if (value !== undefined) { - env[entry.name] = value; - } - } - - return { - name: template.name, - templateId: template.id, - command: template.command, - args: [...template.args], - env, - }; -} - -export function generateMcpJsonEntry(profile: InstantiatedProfile): Record { - return { - [profile.name]: { - command: profile.command, - args: profile.args, - env: profile.env, - }, - }; -} diff --git a/src/shared/src/types/index.ts b/src/shared/src/types/index.ts index bd23840..7fdbbff 100644 --- a/src/shared/src/types/index.ts +++ b/src/shared/src/types/index.ts @@ -6,29 +6,19 @@ export interface McpServerConfig { type: string; command: string; args: string[]; - envTemplate: EnvTemplateEntry[]; + env: EnvEntry[]; setupGuide?: string; } -export interface EnvTemplateEntry { +export interface EnvEntry { name: string; - description: string; - isSecret: boolean; - setupUrl?: string; - defaultValue?: string; -} - -export interface McpProfile { - name: string; - serverId: string; - config: Record; - filterRules?: Record; + value?: string; + valueFrom?: { secretRef: { name: string; key: string } }; } export interface McpProject { name: string; description?: string; - profileIds: string[]; } // Service interfaces for dependency injection -- 2.49.1