feat: replace profiles with kubernetes-style secrets

Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-22 18:40:58 +00:00
parent ede9e10990
commit 6d9a9f572c
77 changed files with 1014 additions and 1931 deletions

View File

@@ -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 {

View File

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

View File

@@ -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++;

View File

@@ -48,9 +48,8 @@ export async function cleanupTestDb(): Promise<void> {
export async function clearAllTables(client: PrismaClient): Promise<void> {
// 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();

View File

@@ -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<string, string>;
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<string, string>;
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 ──

View File

@@ -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);