feat: Kubernetes operator for MCP server management #47
1048
docs/project-summary.md
Normal file
1048
docs/project-summary.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -287,8 +287,6 @@ async function main(): Promise<void> {
|
||||
const auditEventService = new AuditEventService(auditEventRepo);
|
||||
const metricsCollector = new MetricsCollector();
|
||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const templateService = new TemplateService(templateRepo);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||
@@ -301,6 +299,8 @@ async function main(): Promise<void> {
|
||||
const promptRuleRegistry = new ResourceRuleRegistry();
|
||||
promptRuleRegistry.register(systemPromptVarsRule);
|
||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||
|
||||
// Auth middleware for global hooks
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { IProjectRepository } from '../../repositories/project.repository.j
|
||||
import type { IUserRepository } from '../../repositories/user.repository.js';
|
||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
||||
import type { IPromptRepository } from '../../repositories/prompt.repository.js';
|
||||
import type { ITemplateRepository } from '../../repositories/template.repository.js';
|
||||
import { encrypt, isSensitiveKey } from './crypto.js';
|
||||
import type { EncryptedPayload } from './crypto.js';
|
||||
import { APP_VERSION } from '@mcpctl/shared';
|
||||
@@ -18,6 +20,8 @@ export interface BackupBundle {
|
||||
users?: BackupUser[];
|
||||
groups?: BackupGroup[];
|
||||
rbacBindings?: BackupRbacBinding[];
|
||||
prompts?: BackupPrompt[];
|
||||
templates?: BackupTemplate[];
|
||||
encryptedSecrets?: EncryptedPayload;
|
||||
}
|
||||
|
||||
@@ -25,10 +29,16 @@ export interface BackupServer {
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string | null;
|
||||
runtime: string | null;
|
||||
dockerImage: string | null;
|
||||
transport: string;
|
||||
repositoryUrl: string | null;
|
||||
externalUrl: string | null;
|
||||
command: unknown;
|
||||
containerPort: number | null;
|
||||
replicas: number;
|
||||
env: unknown;
|
||||
healthCheck: unknown;
|
||||
}
|
||||
|
||||
export interface BackupSecret {
|
||||
@@ -65,9 +75,31 @@ export interface BackupRbacBinding {
|
||||
roleBindings: unknown;
|
||||
}
|
||||
|
||||
export interface BackupPrompt {
|
||||
name: string;
|
||||
content: string;
|
||||
projectName: string | null;
|
||||
priority: number;
|
||||
summary: string | null;
|
||||
chapters: unknown;
|
||||
linkTarget: string | null;
|
||||
}
|
||||
|
||||
export interface BackupTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string | null;
|
||||
dockerImage: string | null;
|
||||
transport: string;
|
||||
command: unknown;
|
||||
containerPort: number | null;
|
||||
env: unknown;
|
||||
healthCheck: unknown;
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
password?: string;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac' | 'prompts' | 'templates'>;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
@@ -78,10 +110,12 @@ export class BackupService {
|
||||
private userRepo?: IUserRepository,
|
||||
private groupRepo?: IGroupRepository,
|
||||
private rbacRepo?: IRbacDefinitionRepository,
|
||||
private promptRepo?: IPromptRepository,
|
||||
private templateRepo?: ITemplateRepository,
|
||||
) {}
|
||||
|
||||
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
||||
const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac'];
|
||||
const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac', 'prompts', 'templates'];
|
||||
|
||||
let servers: BackupServer[] = [];
|
||||
let secrets: BackupSecret[] = [];
|
||||
@@ -96,10 +130,16 @@ export class BackupService {
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
packageName: s.packageName,
|
||||
runtime: s.runtime,
|
||||
dockerImage: s.dockerImage,
|
||||
transport: s.transport,
|
||||
repositoryUrl: s.repositoryUrl,
|
||||
externalUrl: s.externalUrl,
|
||||
command: s.command,
|
||||
containerPort: s.containerPort,
|
||||
replicas: s.replicas,
|
||||
env: s.env,
|
||||
healthCheck: s.healthCheck,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -151,6 +191,37 @@ export class BackupService {
|
||||
}));
|
||||
}
|
||||
|
||||
let prompts: BackupPrompt[] = [];
|
||||
let templates: BackupTemplate[] = [];
|
||||
|
||||
if (resources.includes('prompts') && this.promptRepo) {
|
||||
const allPrompts = await this.promptRepo.findAll();
|
||||
prompts = allPrompts.map((p) => ({
|
||||
name: p.name,
|
||||
content: p.content,
|
||||
projectName: (p as unknown as { project?: { name: string } }).project?.name ?? null,
|
||||
priority: p.priority,
|
||||
summary: p.summary,
|
||||
chapters: p.chapters,
|
||||
linkTarget: p.linkTarget,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('templates') && this.templateRepo) {
|
||||
const allTemplates = await this.templateRepo.findAll();
|
||||
templates = allTemplates.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
packageName: t.packageName,
|
||||
dockerImage: t.dockerImage,
|
||||
transport: t.transport,
|
||||
command: t.command,
|
||||
containerPort: t.containerPort,
|
||||
env: t.env,
|
||||
healthCheck: t.healthCheck,
|
||||
}));
|
||||
}
|
||||
|
||||
const bundle: BackupBundle = {
|
||||
version: '1',
|
||||
mcpctlVersion: APP_VERSION,
|
||||
@@ -162,6 +233,8 @@ export class BackupService {
|
||||
users,
|
||||
groups,
|
||||
rbacBindings,
|
||||
prompts,
|
||||
templates,
|
||||
};
|
||||
|
||||
if (options?.password && secrets.length > 0) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { IProjectRepository } from '../../repositories/project.repository.j
|
||||
import type { IUserRepository } from '../../repositories/user.repository.js';
|
||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
||||
import type { IPromptRepository } from '../../repositories/prompt.repository.js';
|
||||
import type { ITemplateRepository } from '../../repositories/template.repository.js';
|
||||
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||
import { decrypt } from './crypto.js';
|
||||
import type { BackupBundle } from './backup-service.js';
|
||||
@@ -27,6 +29,10 @@ export interface RestoreResult {
|
||||
groupsSkipped: number;
|
||||
rbacCreated: number;
|
||||
rbacSkipped: number;
|
||||
promptsCreated: number;
|
||||
promptsSkipped: number;
|
||||
templatesCreated: number;
|
||||
templatesSkipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
@@ -38,6 +44,8 @@ export class RestoreService {
|
||||
private userRepo?: IUserRepository,
|
||||
private groupRepo?: IGroupRepository,
|
||||
private rbacRepo?: IRbacDefinitionRepository,
|
||||
private promptRepo?: IPromptRepository,
|
||||
private templateRepo?: ITemplateRepository,
|
||||
) {}
|
||||
|
||||
validateBundle(bundle: unknown): bundle is BackupBundle {
|
||||
@@ -67,6 +75,10 @@ export class RestoreService {
|
||||
groupsSkipped: 0,
|
||||
rbacCreated: 0,
|
||||
rbacSkipped: 0,
|
||||
promptsCreated: 0,
|
||||
promptsSkipped: 0,
|
||||
templatesCreated: 0,
|
||||
templatesSkipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -159,12 +171,17 @@ export class RestoreService {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
replicas: (server as { replicas?: number }).replicas ?? 1,
|
||||
replicas: server.replicas ?? 1,
|
||||
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.runtime) createData.runtime = server.runtime;
|
||||
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
||||
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
|
||||
if (server.externalUrl) createData.externalUrl = server.externalUrl;
|
||||
if (server.command) createData.command = server.command as string[];
|
||||
if (server.containerPort) createData.containerPort = server.containerPort;
|
||||
if (server.healthCheck) createData.healthCheck = server.healthCheck as Parameters<IMcpServerRepository['create']>[0]['healthCheck'];
|
||||
await this.serverRepo.create(createData);
|
||||
result.serversCreated++;
|
||||
} catch (err) {
|
||||
@@ -337,6 +354,87 @@ export class RestoreService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore prompts (after projects, so projectId can be resolved)
|
||||
if (bundle.prompts && this.promptRepo) {
|
||||
for (const prompt of bundle.prompts) {
|
||||
try {
|
||||
// Resolve project by name
|
||||
let projectId: string | undefined;
|
||||
if (prompt.projectName) {
|
||||
const project = await this.projectRepo.findByName(prompt.projectName);
|
||||
if (project) projectId = project.id;
|
||||
}
|
||||
|
||||
const existing = await this.promptRepo.findByNameAndProject(prompt.name, projectId ?? null);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`Prompt "${prompt.name}" already exists`);
|
||||
return result;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.promptsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
const updateData: { content: string; priority: number; summary?: string } = {
|
||||
content: prompt.content,
|
||||
priority: prompt.priority,
|
||||
};
|
||||
if (prompt.summary) updateData.summary = prompt.summary;
|
||||
await this.promptRepo.update(existing.id, updateData);
|
||||
result.promptsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = {
|
||||
name: prompt.name,
|
||||
content: prompt.content,
|
||||
};
|
||||
if (projectId) createData.projectId = projectId;
|
||||
if (prompt.priority !== 5) createData.priority = prompt.priority;
|
||||
if (prompt.linkTarget) createData.linkTarget = prompt.linkTarget;
|
||||
await this.promptRepo.create(createData);
|
||||
result.promptsCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore prompt "${prompt.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore templates
|
||||
if (bundle.templates && this.templateRepo) {
|
||||
for (const tmpl of bundle.templates) {
|
||||
try {
|
||||
const existing = await this.templateRepo.findByName(tmpl.name);
|
||||
if (existing) {
|
||||
if (strategy === 'skip') {
|
||||
result.templatesSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite or fail handled by upsert
|
||||
result.templatesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tmplData: Record<string, unknown> = {
|
||||
name: tmpl.name,
|
||||
description: tmpl.description,
|
||||
transport: tmpl.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
};
|
||||
if (tmpl.packageName) tmplData.packageName = tmpl.packageName;
|
||||
if (tmpl.dockerImage) tmplData.dockerImage = tmpl.dockerImage;
|
||||
if (tmpl.command) tmplData.command = tmpl.command;
|
||||
if (tmpl.containerPort) tmplData.containerPort = tmpl.containerPort;
|
||||
if (tmpl.env) tmplData.env = tmpl.env;
|
||||
if (tmpl.healthCheck) tmplData.healthCheck = tmpl.healthCheck;
|
||||
await this.templateRepo.create(tmplData as Parameters<typeof this.templateRepo.create>[0]);
|
||||
result.templatesCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore template "${tmpl.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export class K8sOfficialClient {
|
||||
readonly kc: k8s.KubeConfig;
|
||||
readonly core: k8s.CoreV1Api;
|
||||
readonly exec: k8s.Exec;
|
||||
readonly attach: k8s.Attach;
|
||||
readonly log: k8s.Log;
|
||||
readonly serversNamespace: string;
|
||||
|
||||
@@ -36,6 +37,7 @@ export class K8sOfficialClient {
|
||||
|
||||
this.core = this.kc.makeApiClient(k8s.CoreV1Api);
|
||||
this.exec = new k8s.Exec(this.kc);
|
||||
this.attach = new k8s.Attach(this.kc);
|
||||
this.log = new k8s.Log(this.kc);
|
||||
this.serversNamespace = opts?.serversNamespace
|
||||
?? process.env['MCPD_SERVERS_NAMESPACE']
|
||||
|
||||
@@ -257,6 +257,46 @@ export class KubernetesOrchestrator implements McpOrchestrator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a running container's main process (PID 1) stdin/stdout.
|
||||
* Used for docker-image STDIO servers where the entrypoint IS the MCP server.
|
||||
*/
|
||||
async attachInteractive(
|
||||
containerId: string,
|
||||
): Promise<InteractiveExec> {
|
||||
const containerName = await this.getContainerName(containerId);
|
||||
const stdout = new PassThrough();
|
||||
const stdinStream = new PassThrough();
|
||||
|
||||
const stderrStream = new Writable({
|
||||
write(_chunk: Buffer, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const ws = await this.client.attach.attach(
|
||||
this.namespace,
|
||||
containerId,
|
||||
containerName,
|
||||
stdout,
|
||||
stderrStream,
|
||||
stdinStream,
|
||||
false, // tty
|
||||
);
|
||||
|
||||
return {
|
||||
stdout,
|
||||
write(data: string) {
|
||||
stdinStream.write(data);
|
||||
},
|
||||
close() {
|
||||
stdinStream.end();
|
||||
stdout.destroy();
|
||||
ws.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async listContainers(namespace?: string): Promise<ContainerInfo[]> {
|
||||
const ns = namespace ?? this.namespace;
|
||||
const podList = await this.client.core.listNamespacedPod({
|
||||
|
||||
@@ -140,8 +140,13 @@ export class McpProxyService {
|
||||
}
|
||||
const packageName = server.packageName as string | null;
|
||||
const command = server.command as string[] | null;
|
||||
|
||||
if (!packageName && (!command || command.length === 0)) {
|
||||
throw new InvalidStateError(`Server '${server.id}' has no packageName or command for STDIO transport`);
|
||||
throw new InvalidStateError(
|
||||
`Server '${server.name}' (${server.id}) uses STDIO transport with a docker image ` +
|
||||
`but has no command. Set 'command' to the image's entrypoint ` +
|
||||
`(e.g. mcpctl edit server ${server.name} --command node --command build/index.js)`
|
||||
);
|
||||
}
|
||||
|
||||
// Build the spawn command based on runtime
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface McpOrchestrator {
|
||||
/** Start a long-running interactive exec session (bidirectional stdio stream). */
|
||||
execInteractive?(containerId: string, cmd: string[]): Promise<InteractiveExec>;
|
||||
|
||||
/** Attach to a running container's main process stdin/stdout (PID 1). */
|
||||
attachInteractive?(containerId: string): Promise<InteractiveExec>;
|
||||
|
||||
/** Check if the orchestrator runtime is available */
|
||||
ping(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ vi.mock('@kubernetes/client-node', () => {
|
||||
exec = vi.fn();
|
||||
}
|
||||
|
||||
class MockAttach {
|
||||
attach = vi.fn();
|
||||
}
|
||||
|
||||
class MockLog {
|
||||
log = vi.fn();
|
||||
}
|
||||
@@ -75,6 +79,7 @@ vi.mock('@kubernetes/client-node', () => {
|
||||
KubeConfig: MockKubeConfig,
|
||||
CoreV1Api: class {},
|
||||
Exec: MockExec,
|
||||
Attach: MockAttach,
|
||||
Log: MockLog,
|
||||
// Export test helpers
|
||||
__testHelpers: { setHandler, getHandler, clearHandlers, mockCore },
|
||||
|
||||
22
templates/gitea.yaml
Normal file
22
templates/gitea.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: gitea
|
||||
version: "1.0.0"
|
||||
description: Gitea MCP server for repositories, issues, PRs, and code management
|
||||
dockerImage: "docker.gitea.com/gitea-mcp-server:latest"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://gitea.com/gitea/gitea-mcp
|
||||
command:
|
||||
- /app/gitea-mcp
|
||||
- -t
|
||||
- stdio
|
||||
# Health check disabled: STDIO health probe requires packageName (npm-based servers).
|
||||
# This server uses a custom dockerImage. Probe support for dockerImage STDIO servers is TODO.
|
||||
env:
|
||||
- name: GITEA_HOST
|
||||
description: Gitea instance URL (e.g. https://gitea.example.com)
|
||||
required: true
|
||||
- name: GITEA_ACCESS_TOKEN
|
||||
description: Gitea personal access token
|
||||
required: true
|
||||
- name: GITEA_INSECURE
|
||||
description: Allow self-signed certificates (true/false, default false)
|
||||
required: false
|
||||
25
templates/unifi-network.yaml
Normal file
25
templates/unifi-network.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: unifi-network
|
||||
version: "1.0.0"
|
||||
description: UniFi Network MCP server for managing UniFi network devices, clients, and configuration
|
||||
packageName: "unifi-network-mcp"
|
||||
runtime: python
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/sirkirby/unifi-mcp
|
||||
# Health check disabled: STDIO health probe requires packageName (npm-based servers).
|
||||
# This server uses the Python runner. Probe support for Python runner STDIO servers is TODO.
|
||||
env:
|
||||
- name: UNIFI_HOST
|
||||
description: UniFi controller hostname or IP (e.g. unifi.example.com — without https://)
|
||||
required: true
|
||||
- name: UNIFI_USERNAME
|
||||
description: UniFi local admin username
|
||||
required: true
|
||||
- name: UNIFI_PASSWORD
|
||||
description: UniFi admin password
|
||||
required: true
|
||||
- name: UNIFI_NETWORK_PORT
|
||||
description: UniFi controller port (default 443, use 8443 for standalone UniFi Controller)
|
||||
required: false
|
||||
- name: UNIFI_NETWORK_VERIFY_SSL
|
||||
description: Verify SSL certificate (true/false, default true — set false for self-signed certs)
|
||||
required: false
|
||||
Reference in New Issue
Block a user