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:
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 auditEventService = new AuditEventService(auditEventRepo);
|
||||||
const metricsCollector = new MetricsCollector();
|
const metricsCollector = new MetricsCollector();
|
||||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
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 authService = new AuthService(prisma);
|
||||||
const templateService = new TemplateService(templateRepo);
|
const templateService = new TemplateService(templateRepo);
|
||||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||||
@@ -301,6 +299,8 @@ async function main(): Promise<void> {
|
|||||||
const promptRuleRegistry = new ResourceRuleRegistry();
|
const promptRuleRegistry = new ResourceRuleRegistry();
|
||||||
promptRuleRegistry.register(systemPromptVarsRule);
|
promptRuleRegistry.register(systemPromptVarsRule);
|
||||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
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
|
// Auth middleware for global hooks
|
||||||
const authMiddleware = createAuthMiddleware({
|
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 { IUserRepository } from '../../repositories/user.repository.js';
|
||||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.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 { encrypt, isSensitiveKey } from './crypto.js';
|
||||||
import type { EncryptedPayload } from './crypto.js';
|
import type { EncryptedPayload } from './crypto.js';
|
||||||
import { APP_VERSION } from '@mcpctl/shared';
|
import { APP_VERSION } from '@mcpctl/shared';
|
||||||
@@ -18,6 +20,8 @@ export interface BackupBundle {
|
|||||||
users?: BackupUser[];
|
users?: BackupUser[];
|
||||||
groups?: BackupGroup[];
|
groups?: BackupGroup[];
|
||||||
rbacBindings?: BackupRbacBinding[];
|
rbacBindings?: BackupRbacBinding[];
|
||||||
|
prompts?: BackupPrompt[];
|
||||||
|
templates?: BackupTemplate[];
|
||||||
encryptedSecrets?: EncryptedPayload;
|
encryptedSecrets?: EncryptedPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +29,16 @@ export interface BackupServer {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
packageName: string | null;
|
packageName: string | null;
|
||||||
|
runtime: string | null;
|
||||||
dockerImage: string | null;
|
dockerImage: string | null;
|
||||||
transport: string;
|
transport: string;
|
||||||
repositoryUrl: string | null;
|
repositoryUrl: string | null;
|
||||||
|
externalUrl: string | null;
|
||||||
|
command: unknown;
|
||||||
|
containerPort: number | null;
|
||||||
|
replicas: number;
|
||||||
env: unknown;
|
env: unknown;
|
||||||
|
healthCheck: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupSecret {
|
export interface BackupSecret {
|
||||||
@@ -65,9 +75,31 @@ export interface BackupRbacBinding {
|
|||||||
roleBindings: unknown;
|
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 {
|
export interface BackupOptions {
|
||||||
password?: string;
|
password?: string;
|
||||||
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
|
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac' | 'prompts' | 'templates'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BackupService {
|
export class BackupService {
|
||||||
@@ -78,10 +110,12 @@ export class BackupService {
|
|||||||
private userRepo?: IUserRepository,
|
private userRepo?: IUserRepository,
|
||||||
private groupRepo?: IGroupRepository,
|
private groupRepo?: IGroupRepository,
|
||||||
private rbacRepo?: IRbacDefinitionRepository,
|
private rbacRepo?: IRbacDefinitionRepository,
|
||||||
|
private promptRepo?: IPromptRepository,
|
||||||
|
private templateRepo?: ITemplateRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
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 servers: BackupServer[] = [];
|
||||||
let secrets: BackupSecret[] = [];
|
let secrets: BackupSecret[] = [];
|
||||||
@@ -96,10 +130,16 @@ export class BackupService {
|
|||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description,
|
description: s.description,
|
||||||
packageName: s.packageName,
|
packageName: s.packageName,
|
||||||
|
runtime: s.runtime,
|
||||||
dockerImage: s.dockerImage,
|
dockerImage: s.dockerImage,
|
||||||
transport: s.transport,
|
transport: s.transport,
|
||||||
repositoryUrl: s.repositoryUrl,
|
repositoryUrl: s.repositoryUrl,
|
||||||
|
externalUrl: s.externalUrl,
|
||||||
|
command: s.command,
|
||||||
|
containerPort: s.containerPort,
|
||||||
|
replicas: s.replicas,
|
||||||
env: s.env,
|
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 = {
|
const bundle: BackupBundle = {
|
||||||
version: '1',
|
version: '1',
|
||||||
mcpctlVersion: APP_VERSION,
|
mcpctlVersion: APP_VERSION,
|
||||||
@@ -162,6 +233,8 @@ export class BackupService {
|
|||||||
users,
|
users,
|
||||||
groups,
|
groups,
|
||||||
rbacBindings,
|
rbacBindings,
|
||||||
|
prompts,
|
||||||
|
templates,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.password && secrets.length > 0) {
|
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 { IUserRepository } from '../../repositories/user.repository.js';
|
||||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.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 type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||||
import { decrypt } from './crypto.js';
|
import { decrypt } from './crypto.js';
|
||||||
import type { BackupBundle } from './backup-service.js';
|
import type { BackupBundle } from './backup-service.js';
|
||||||
@@ -27,6 +29,10 @@ export interface RestoreResult {
|
|||||||
groupsSkipped: number;
|
groupsSkipped: number;
|
||||||
rbacCreated: number;
|
rbacCreated: number;
|
||||||
rbacSkipped: number;
|
rbacSkipped: number;
|
||||||
|
promptsCreated: number;
|
||||||
|
promptsSkipped: number;
|
||||||
|
templatesCreated: number;
|
||||||
|
templatesSkipped: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +44,8 @@ export class RestoreService {
|
|||||||
private userRepo?: IUserRepository,
|
private userRepo?: IUserRepository,
|
||||||
private groupRepo?: IGroupRepository,
|
private groupRepo?: IGroupRepository,
|
||||||
private rbacRepo?: IRbacDefinitionRepository,
|
private rbacRepo?: IRbacDefinitionRepository,
|
||||||
|
private promptRepo?: IPromptRepository,
|
||||||
|
private templateRepo?: ITemplateRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
validateBundle(bundle: unknown): bundle is BackupBundle {
|
validateBundle(bundle: unknown): bundle is BackupBundle {
|
||||||
@@ -67,6 +75,10 @@ export class RestoreService {
|
|||||||
groupsSkipped: 0,
|
groupsSkipped: 0,
|
||||||
rbacCreated: 0,
|
rbacCreated: 0,
|
||||||
rbacSkipped: 0,
|
rbacSkipped: 0,
|
||||||
|
promptsCreated: 0,
|
||||||
|
promptsSkipped: 0,
|
||||||
|
templatesCreated: 0,
|
||||||
|
templatesSkipped: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,12 +171,17 @@ export class RestoreService {
|
|||||||
name: server.name,
|
name: server.name,
|
||||||
description: server.description,
|
description: server.description,
|
||||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
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 } } }>,
|
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||||
};
|
};
|
||||||
if (server.packageName) createData.packageName = server.packageName;
|
if (server.packageName) createData.packageName = server.packageName;
|
||||||
|
if (server.runtime) createData.runtime = server.runtime;
|
||||||
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
||||||
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
|
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);
|
await this.serverRepo.create(createData);
|
||||||
result.serversCreated++;
|
result.serversCreated++;
|
||||||
} catch (err) {
|
} 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class K8sOfficialClient {
|
|||||||
readonly kc: k8s.KubeConfig;
|
readonly kc: k8s.KubeConfig;
|
||||||
readonly core: k8s.CoreV1Api;
|
readonly core: k8s.CoreV1Api;
|
||||||
readonly exec: k8s.Exec;
|
readonly exec: k8s.Exec;
|
||||||
|
readonly attach: k8s.Attach;
|
||||||
readonly log: k8s.Log;
|
readonly log: k8s.Log;
|
||||||
readonly serversNamespace: string;
|
readonly serversNamespace: string;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export class K8sOfficialClient {
|
|||||||
|
|
||||||
this.core = this.kc.makeApiClient(k8s.CoreV1Api);
|
this.core = this.kc.makeApiClient(k8s.CoreV1Api);
|
||||||
this.exec = new k8s.Exec(this.kc);
|
this.exec = new k8s.Exec(this.kc);
|
||||||
|
this.attach = new k8s.Attach(this.kc);
|
||||||
this.log = new k8s.Log(this.kc);
|
this.log = new k8s.Log(this.kc);
|
||||||
this.serversNamespace = opts?.serversNamespace
|
this.serversNamespace = opts?.serversNamespace
|
||||||
?? process.env['MCPD_SERVERS_NAMESPACE']
|
?? 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[]> {
|
async listContainers(namespace?: string): Promise<ContainerInfo[]> {
|
||||||
const ns = namespace ?? this.namespace;
|
const ns = namespace ?? this.namespace;
|
||||||
const podList = await this.client.core.listNamespacedPod({
|
const podList = await this.client.core.listNamespacedPod({
|
||||||
|
|||||||
@@ -140,8 +140,13 @@ export class McpProxyService {
|
|||||||
}
|
}
|
||||||
const packageName = server.packageName as string | null;
|
const packageName = server.packageName as string | null;
|
||||||
const command = server.command as string[] | null;
|
const command = server.command as string[] | null;
|
||||||
|
|
||||||
if (!packageName && (!command || command.length === 0)) {
|
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
|
// Build the spawn command based on runtime
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface McpOrchestrator {
|
|||||||
/** Start a long-running interactive exec session (bidirectional stdio stream). */
|
/** Start a long-running interactive exec session (bidirectional stdio stream). */
|
||||||
execInteractive?(containerId: string, cmd: string[]): Promise<InteractiveExec>;
|
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 */
|
/** Check if the orchestrator runtime is available */
|
||||||
ping(): Promise<boolean>;
|
ping(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ vi.mock('@kubernetes/client-node', () => {
|
|||||||
exec = vi.fn();
|
exec = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MockAttach {
|
||||||
|
attach = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
class MockLog {
|
class MockLog {
|
||||||
log = vi.fn();
|
log = vi.fn();
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,7 @@ vi.mock('@kubernetes/client-node', () => {
|
|||||||
KubeConfig: MockKubeConfig,
|
KubeConfig: MockKubeConfig,
|
||||||
CoreV1Api: class {},
|
CoreV1Api: class {},
|
||||||
Exec: MockExec,
|
Exec: MockExec,
|
||||||
|
Attach: MockAttach,
|
||||||
Log: MockLog,
|
Log: MockLog,
|
||||||
// Export test helpers
|
// Export test helpers
|
||||||
__testHelpers: { setHandler, getHandler, clearHandlers, mockCore },
|
__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