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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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<string, ProfileTemplate>();
|
||||
|
||||
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<ProfileCategory>();
|
||||
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();
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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<typeof profileTemplateSchema>;
|
||||
|
||||
export type ProfileCategory = ProfileTemplate['category'];
|
||||
|
||||
export interface InstantiatedProfile {
|
||||
name: string;
|
||||
templateId: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
@@ -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, string>): string[] {
|
||||
return template.requiredEnvVars
|
||||
.filter((e) => !envValues[e.name] && e.defaultValue === undefined)
|
||||
.map((e) => e.name);
|
||||
}
|
||||
|
||||
export function instantiateProfile(
|
||||
template: ProfileTemplate,
|
||||
envValues: Record<string, string>,
|
||||
): InstantiatedProfile {
|
||||
const missing = getMissingEnvVars(template, envValues);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
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<string, unknown> {
|
||||
return {
|
||||
[profile.name]: {
|
||||
command: profile.command,
|
||||
args: profile.args,
|
||||
env: profile.env,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
filterRules?: Record<string, unknown>;
|
||||
value?: string;
|
||||
valueFrom?: { secretRef: { name: string; key: string } };
|
||||
}
|
||||
|
||||
export interface McpProject {
|
||||
name: string;
|
||||
description?: string;
|
||||
profileIds: string[];
|
||||
}
|
||||
|
||||
// Service interfaces for dependency injection
|
||||
|
||||
Reference in New Issue
Block a user