feat: add MCP server templates and deployment infrastructure
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:
@@ -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> {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user