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:
@@ -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 {
|
||||
|
||||
@@ -4,9 +4,8 @@ export type {
|
||||
User,
|
||||
Session,
|
||||
McpServer,
|
||||
McpProfile,
|
||||
Secret,
|
||||
Project,
|
||||
ProjectMcpProfile,
|
||||
McpInstance,
|
||||
AuditLog,
|
||||
Role,
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user