feat: replace profiles with kubernetes-style secrets
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

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 02254f2aac
commit ca02340a4c
77 changed files with 1014 additions and 1931 deletions

View File

@@ -1,22 +1,8 @@
import { describe, it, expect } from 'vitest';
import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js';
import type { McpServer } from '@prisma/client';
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] {
return {
id: 'p1',
name: 'default',
serverId: 's1',
permissions: [],
envOverrides: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
return {
id: 's1',
name: 'slack',
@@ -25,7 +11,7 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -34,76 +20,51 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
}
describe('generateMcpConfig', () => {
it('returns empty mcpServers for empty profiles', () => {
it('returns empty mcpServers for empty input', () => {
const result = generateMcpConfig([]);
expect(result).toEqual({ mcpServers: {} });
});
it('generates config for a single profile', () => {
it('generates config for a single server', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server: makeServer() },
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']).toBeDefined();
expect(result.mcpServers['slack--default']?.command).toBe('npx');
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['slack']?.command).toBe('npx');
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
});
it('excludes secret env vars from output', () => {
const server = makeServer({
envTemplate: [
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
] as never,
});
it('includes resolved env when present', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
]);
const config = result.mcpServers['slack--default'];
const config = result.mcpServers['slack'];
expect(config?.env).toBeDefined();
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
});
it('applies env overrides from profile (non-secret only)', () => {
const server = makeServer({
envTemplate: [
{ name: 'API_URL', description: 'URL', isSecret: false },
] as never,
});
const profile = makeProfile({
envOverrides: { API_URL: 'https://staging.example.com' } as never,
});
const result = generateMcpConfig([{ profile, server }]);
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
it('omits env when resolvedEnv is empty', () => {
const result = generateMcpConfig([
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack']?.env).toBeUndefined();
});
it('generates multiple server configs', () => {
const result = generateMcpConfig([
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
{ server: makeServer({ name: 'slack' }), resolvedEnv: {} },
{ server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }), resolvedEnv: {} },
]);
expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['slack--readonly']).toBeDefined();
expect(result.mcpServers['github--default']).toBeDefined();
});
it('omits env when no non-secret vars have values', () => {
const server = makeServer({
envTemplate: [
{ name: 'TOKEN', description: 'Secret', isSecret: true },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['github']).toBeDefined();
});
it('uses server name as fallback when packageName is null', () => {
const server = makeServer({ packageName: null });
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server, resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']);
expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
});
});