- Remove ProjectMember model entirely (RBAC manages project access) - Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose) - Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model - Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER) - Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach - Remove members from backup/restore, apply, get, describe - Prisma migration to drop ProjectMember table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { Command } from 'commander';
|
|
import { readFileSync } from 'node:fs';
|
|
import yaml from 'js-yaml';
|
|
import { z } from 'zod';
|
|
import type { ApiClient } from '../api-client.js';
|
|
|
|
const HealthCheckSchema = z.object({
|
|
tool: z.string().min(1),
|
|
arguments: z.record(z.unknown()).default({}),
|
|
intervalSeconds: z.number().int().min(5).max(3600).default(60),
|
|
timeoutSeconds: z.number().int().min(1).max(120).default(10),
|
|
failureThreshold: z.number().int().min(1).max(20).default(3),
|
|
});
|
|
|
|
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(''),
|
|
packageName: z.string().optional(),
|
|
dockerImage: z.string().optional(),
|
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
|
repositoryUrl: z.string().url().optional(),
|
|
externalUrl: z.string().url().optional(),
|
|
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),
|
|
env: z.array(ServerEnvEntrySchema).default([]),
|
|
healthCheck: HealthCheckSchema.optional(),
|
|
});
|
|
|
|
const SecretSpecSchema = z.object({
|
|
name: z.string().min(1),
|
|
data: z.record(z.string()).default({}),
|
|
});
|
|
|
|
const TemplateEnvEntrySchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
required: z.boolean().optional(),
|
|
defaultValue: z.string().optional(),
|
|
});
|
|
|
|
const TemplateSpecSchema = z.object({
|
|
name: z.string().min(1),
|
|
version: z.string().default('1.0.0'),
|
|
description: z.string().default(''),
|
|
packageName: z.string().optional(),
|
|
dockerImage: z.string().optional(),
|
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
|
repositoryUrl: z.string().optional(),
|
|
externalUrl: z.string().optional(),
|
|
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),
|
|
env: z.array(TemplateEnvEntrySchema).default([]),
|
|
healthCheck: HealthCheckSchema.optional(),
|
|
});
|
|
|
|
const UserSpecSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
name: z.string().optional(),
|
|
});
|
|
|
|
const GroupSpecSchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().default(''),
|
|
members: z.array(z.string().email()).default([]),
|
|
});
|
|
|
|
const RbacSubjectSchema = z.object({
|
|
kind: z.enum(['User', 'Group']),
|
|
name: z.string().min(1),
|
|
});
|
|
|
|
const RESOURCE_ALIASES: Record<string, string> = {
|
|
server: 'servers', instance: 'instances', secret: 'secrets',
|
|
project: 'projects', template: 'templates', user: 'users', group: 'groups',
|
|
};
|
|
|
|
const RbacRoleBindingSchema = z.union([
|
|
z.object({
|
|
role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
|
|
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
|
|
name: z.string().min(1).optional(),
|
|
}),
|
|
z.object({
|
|
role: z.literal('run'),
|
|
action: z.string().min(1),
|
|
}),
|
|
]);
|
|
|
|
const RbacBindingSpecSchema = z.object({
|
|
name: z.string().min(1),
|
|
subjects: z.array(RbacSubjectSchema).default([]),
|
|
roleBindings: z.array(RbacRoleBindingSchema).default([]),
|
|
});
|
|
|
|
const ProjectSpecSchema = z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().default(''),
|
|
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
|
llmProvider: z.string().optional(),
|
|
llmModel: z.string().optional(),
|
|
servers: z.array(z.string()).default([]),
|
|
});
|
|
|
|
const ApplyConfigSchema = z.object({
|
|
secrets: z.array(SecretSpecSchema).default([]),
|
|
servers: z.array(ServerSpecSchema).default([]),
|
|
users: z.array(UserSpecSchema).default([]),
|
|
groups: z.array(GroupSpecSchema).default([]),
|
|
projects: z.array(ProjectSpecSchema).default([]),
|
|
templates: z.array(TemplateSpecSchema).default([]),
|
|
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
|
rbac: z.array(RbacBindingSpecSchema).default([]),
|
|
}).transform((data) => ({
|
|
...data,
|
|
// Merge rbac into rbacBindings so both keys work
|
|
rbacBindings: [...data.rbacBindings, ...data.rbac],
|
|
}));
|
|
|
|
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
|
|
|
|
export interface ApplyCommandDeps {
|
|
client: ApiClient;
|
|
log: (...args: unknown[]) => void;
|
|
}
|
|
|
|
export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
|
const { client, log } = deps;
|
|
|
|
return new Command('apply')
|
|
.description('Apply declarative configuration from a YAML or JSON file')
|
|
.argument('[file]', 'Path to config file (.yaml, .yml, or .json)')
|
|
.option('-f, --file <file>', 'Path to config file (alternative to positional arg)')
|
|
.option('--dry-run', 'Validate and show changes without applying')
|
|
.action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => {
|
|
const file = fileArg ?? opts.file;
|
|
if (!file) {
|
|
throw new Error('File path required. Usage: mcpctl apply <file> or mcpctl apply -f <file>');
|
|
}
|
|
const config = loadConfigFile(file);
|
|
|
|
if (opts.dryRun) {
|
|
log('Dry run - would apply:');
|
|
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
|
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
|
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
|
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
|
|
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
|
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
|
|
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
|
|
return;
|
|
}
|
|
|
|
await applyConfig(client, config, log);
|
|
});
|
|
}
|
|
|
|
function loadConfigFile(path: string): ApplyConfig {
|
|
const raw = readFileSync(path, 'utf-8');
|
|
let parsed: unknown;
|
|
|
|
if (path.endsWith('.json')) {
|
|
parsed = JSON.parse(raw);
|
|
} else {
|
|
parsed = yaml.load(raw);
|
|
}
|
|
|
|
return ApplyConfigSchema.parse(parsed);
|
|
}
|
|
|
|
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
|
|
// Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
|
|
|
|
// Apply secrets
|
|
for (const secret of config.secrets) {
|
|
try {
|
|
const existing = await findByName(client, 'secrets', secret.name);
|
|
if (existing) {
|
|
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/secrets', secret);
|
|
log(`Created secret: ${secret.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply servers
|
|
for (const server of config.servers) {
|
|
try {
|
|
const existing = await findByName(client, 'servers', server.name);
|
|
if (existing) {
|
|
await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server);
|
|
log(`Updated server: ${server.name}`);
|
|
} else {
|
|
await client.post('/api/v1/servers', server);
|
|
log(`Created server: ${server.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply users (matched by email)
|
|
for (const user of config.users) {
|
|
try {
|
|
const existing = await findByField(client, 'users', 'email', user.email);
|
|
if (existing) {
|
|
await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
|
|
log(`Updated user: ${user.email}`);
|
|
} else {
|
|
await client.post('/api/v1/users', user);
|
|
log(`Created user: ${user.email}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply groups
|
|
for (const group of config.groups) {
|
|
try {
|
|
const existing = await findByName(client, 'groups', group.name);
|
|
if (existing) {
|
|
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
|
|
log(`Updated group: ${group.name}`);
|
|
} else {
|
|
await client.post('/api/v1/groups', group);
|
|
log(`Created group: ${group.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply projects (send full spec including servers)
|
|
for (const project of config.projects) {
|
|
try {
|
|
const existing = await findByName(client, 'projects', project.name);
|
|
if (existing) {
|
|
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
|
|
log(`Updated project: ${project.name}`);
|
|
} else {
|
|
await client.post('/api/v1/projects', project);
|
|
log(`Created project: ${project.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply templates
|
|
for (const template of config.templates) {
|
|
try {
|
|
const existing = await findByName(client, 'templates', template.name);
|
|
if (existing) {
|
|
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
|
|
log(`Updated template: ${template.name}`);
|
|
} else {
|
|
await client.post('/api/v1/templates', template);
|
|
log(`Created template: ${template.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
// Apply RBAC bindings
|
|
for (const rbacBinding of config.rbacBindings) {
|
|
try {
|
|
const existing = await findByName(client, 'rbac', rbacBinding.name);
|
|
if (existing) {
|
|
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
|
|
log(`Updated rbacBinding: ${rbacBinding.name}`);
|
|
} else {
|
|
await client.post('/api/v1/rbac', rbacBinding);
|
|
log(`Created rbacBinding: ${rbacBinding.name}`);
|
|
}
|
|
} catch (err) {
|
|
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
|
try {
|
|
const items = await client.get<Array<{ name: string }>>(`/api/v1/${resource}`);
|
|
return items.find((item) => item.name === name) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
|
|
try {
|
|
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
|
|
return items.find((item) => item[field] === value) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Export for testing
|
|
export { loadConfigFile, applyConfig };
|