feat: add MCP server templates and deployment infrastructure
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

Introduce a Helm-chart-like template system for MCP servers. Templates are
YAML files in templates/ that get seeded into the DB on startup. Users can
browse them with `mcpctl get templates`, inspect with `mcpctl describe
template`, and instantiate with `mcpctl create server --from-template=`.

Also adds Portainer deployment scripts, mcplocal systemd service,
Streamable HTTP MCP endpoint, and RPM packaging for mcpctl-local.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-22 22:24:35 +00:00
parent 1e8847bb63
commit d58e6e153f
46 changed files with 1299 additions and 338 deletions

View File

@@ -31,6 +31,28 @@ const SecretSpecSchema = z.object({
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([]),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
@@ -40,6 +62,7 @@ const ApplyConfigSchema = z.object({
servers: z.array(ServerSpecSchema).default([]),
secrets: z.array(SecretSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]),
templates: z.array(TemplateSpecSchema).default([]),
});
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
@@ -64,6 +87,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
return;
}
@@ -137,6 +161,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
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}`);
}
}
}
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {

View File

@@ -61,30 +61,88 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('server')
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description', '')
.option('-d, --description <text>', 'Server description')
.option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
.option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas', '1')
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--from-template <name>', 'Create from template (name or name:version)')
.action(async (name: string, opts) => {
let base: Record<string, unknown> = {};
// If --from-template, fetch template and use as base
if (opts.fromTemplate) {
const tplRef = opts.fromTemplate as string;
const [tplName, tplVersion] = tplRef.includes(':')
? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
: [tplRef, undefined];
const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
let template: Record<string, unknown> | undefined;
if (tplVersion) {
template = templates.find((t) => t.name === tplName && t.version === tplVersion);
if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
} else {
template = templates.find((t) => t.name === tplName);
if (!template) throw new Error(`Template '${tplName}' not found`);
}
// Copy template fields as base (strip template-only fields)
const { id: _id, createdAt: _c, updatedAt: _u, ...tplFields } = template;
base = { ...tplFields };
// Convert template env (description/required) to server env (name/value/valueFrom)
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (tplEnv && tplEnv.length > 0) {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
}
// Track template origin
base.templateName = tplName;
base.templateVersion = (template.version as string) ?? '1.0.0';
}
// Build body: template base → CLI overrides (last wins)
const body: Record<string, unknown> = {
...base,
name,
description: opts.description,
transport: opts.transport,
replicas: parseInt(opts.replicas, 10),
};
if (opts.description !== undefined) body.description = opts.description;
if (opts.transport) body.transport = opts.transport;
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
if (opts.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.env.length > 0) body.env = parseServerEnv(opts.env);
if (opts.env.length > 0) {
// Merge: CLI env entries override template env entries by name
const cliEnv = parseServerEnv(opts.env);
const existing = (body.env as ServerEnvEntry[] | undefined) ?? [];
const merged = [...existing];
for (const entry of cliEnv) {
const idx = merged.findIndex((e) => e.name === entry.name);
if (idx >= 0) {
merged[idx] = entry;
} else {
merged.push(entry);
}
}
body.env = merged;
}
// Defaults when no template
if (!opts.fromTemplate) {
if (body.description === undefined) body.description = '';
if (!body.transport) body.transport = 'STDIO';
if (!body.replicas) body.replicas = 1;
}
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);

View File

@@ -143,6 +143,53 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
return lines.join('\n');
}
function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`);
lines.push(`${pad('Name:')}${template.name}`);
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
const command = template.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment Variables:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
for (const e of env) {
const req = e.required ? 'yes' : 'no';
const desc = e.description ?? '';
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
}
}
lines.push('');
lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${template.id}`);
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
@@ -216,6 +263,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'secrets':
deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'templates':
deps.log(formatTemplateDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;

View File

@@ -30,6 +30,15 @@ interface SecretRow {
data: Record<string, string>;
}
interface TemplateRow {
id: string;
name: string;
version: string;
transport: string;
packageName: string | null;
description: string;
}
interface InstanceRow {
id: string;
serverId: string;
@@ -59,6 +68,14 @@ const secretColumns: Column<SecretRow>[] = [
{ header: 'ID', key: 'id' },
];
const templateColumns: Column<TemplateRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'VERSION', key: 'version', width: 10 },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
@@ -75,6 +92,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'secrets':
return secretColumns as unknown as Column<Record<string, unknown>>[];
case 'templates':
return templateColumns as unknown as Column<Record<string, unknown>>[];
case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[];
default:

View File

@@ -9,6 +9,8 @@ export const RESOURCE_ALIASES: Record<string, string> = {
inst: 'instances',
secret: 'secrets',
sec: 'secrets',
template: 'templates',
tpl: 'templates',
};
export function resolveResource(name: string): string {

View File

@@ -50,6 +50,10 @@ export function createProgram(): Command {
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);