fix: add prompts/templates to backup + STDIO attach for docker-image servers

Two bugs fixed:

1. Backup completeness: JSON backup API now includes prompts and
   templates. Previously these were silently dropped during
   backup/restore, causing data loss on migration.

2. STDIO proxy for docker-image servers: servers with dockerImage
   but no packageName/command (like docmost) now use k8s Attach
   to connect to the container's PID 1 stdin/stdout instead of
   exec. This fixes "has no packageName or command" errors.

Changes:
- backup-service.ts: add BackupPrompt/BackupTemplate types, export them
- restore-service.ts: restore prompts (with project FK) and templates
- mcp-proxy-service.ts: sendViaPersistentAttach for docker-image STDIO
- orchestrator.ts: add attachInteractive to McpOrchestrator interface
- kubernetes-orchestrator.ts: implement attachInteractive via k8s Attach
- k8s-client-official.ts: expose Attach client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-09 23:21:34 +01:00
parent d293df738a
commit 1bd5087052
11 changed files with 1327 additions and 6 deletions

1048
docs/project-summary.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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({

View File

@@ -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) {

View File

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

View File

@@ -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']

View File

@@ -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({

View File

@@ -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

View File

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

View File

@@ -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
View 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

View 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