Files
mcpctl/src/cli/src/commands/apply.ts
Michal 783cf15179
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
feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
- 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>
2026-02-23 17:50:01 +00:00

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 };