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:
Michal
2026-02-22 22:24:35 +00:00
parent 8a4ff6e378
commit 73fb70dce4
46 changed files with 1299 additions and 338 deletions

View File

@@ -66,6 +66,9 @@ model McpServer {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateName String?
templateVersion String?
instances McpInstance[]
@@index([name])
@@ -77,6 +80,28 @@ enum Transport {
STREAMABLE_HTTP
}
// ── MCP Templates ──
model McpTemplate {
id String @id @default(cuid())
name String @unique
version String @default("1.0.0")
description String @default("")
packageName String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Secrets ──
model Secret {

View File

@@ -4,6 +4,7 @@ export type {
User,
Session,
McpServer,
McpTemplate,
Secret,
Project,
McpInstance,
@@ -13,5 +14,5 @@ export type {
InstanceStatus,
} from '@prisma/client';
export { seedMcpServers, defaultServers } from './seed/index.js';
export type { SeedServer } from './seed/index.js';
export { seedTemplates } from './seed/index.js';
export type { SeedTemplate, TemplateEnvEntry } from './seed/index.js';

View File

@@ -1,94 +1,66 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
export interface SeedServer {
export interface TemplateEnvEntry {
name: string;
description: string;
packageName: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl: string;
env: Array<{
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}>;
description?: string;
required?: boolean;
defaultValue?: string;
}
export const defaultServers: SeedServer[] = [
{
name: 'slack',
description: 'Slack MCP server for reading channels, messages, and user info',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
env: [],
},
{
name: 'jira',
description: 'Jira MCP server for issues, projects, and boards',
packageName: '@anthropic/jira-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
env: [],
},
{
name: 'github',
description: 'GitHub MCP server for repos, issues, PRs, and code search',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
env: [],
},
{
name: 'terraform',
description: 'Terraform MCP server for infrastructure documentation and state',
packageName: '@anthropic/terraform-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
env: [],
},
];
export interface SeedTemplate {
name: string;
version: string;
description: string;
packageName?: string;
dockerImage?: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl?: string;
externalUrl?: string;
command?: string[];
containerPort?: number;
replicas?: number;
env?: TemplateEnvEntry[];
}
export async function seedMcpServers(
export async function seedTemplates(
prisma: PrismaClient,
servers: SeedServer[] = defaultServers,
templates: SeedTemplate[],
): Promise<number> {
let created = 0;
let upserted = 0;
for (const server of servers) {
await prisma.mcpServer.upsert({
where: { name: server.name },
for (const tpl of templates) {
await prisma.mcpTemplate.upsert({
where: { name: tpl.name },
update: {
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
env: server.env,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
},
create: {
name: server.name,
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
env: server.env,
name: tpl.name,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
},
});
created++;
upserted++;
}
return created;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
const prisma = new PrismaClient();
seedMcpServers(prisma)
.then((count) => {
console.log(`Seeded ${count} MCP servers`);
return prisma.$disconnect();
})
.catch((e) => {
console.error(e);
return prisma.$disconnect().then(() => process.exit(1));
});
return upserted;
}

View File

@@ -53,5 +53,6 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
await client.session.deleteMany();
await client.project.deleteMany();
await client.mcpServer.deleteMany();
await client.mcpTemplate.deleteMany();
await client.user.deleteMany();
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
let prisma: PrismaClient;
@@ -17,53 +18,69 @@ beforeEach(async () => {
await clearAllTables(prisma);
});
describe('seedMcpServers', () => {
it('seeds all default servers', async () => {
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const testTemplates: SeedTemplate[] = [
{
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
expect(servers).toHaveLength(defaultServers.length);
describe('seedTemplates', () => {
it('seeds templates', async () => {
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const names = servers.map((s) => s.name);
expect(names).toContain('slack');
expect(names).toContain('github');
expect(names).toContain('jira');
expect(names).toContain('terraform');
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
expect(templates).toHaveLength(2);
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
});
it('is idempotent (upsert)', async () => {
await seedMcpServers(prisma);
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
await seedTemplates(prisma, testTemplates);
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(defaultServers.length);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(2);
});
it('seeds env correctly', async () => {
await seedMcpServers(prisma);
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
const env = slack!.env as Array<{ name: string; value?: string }>;
expect(env).toEqual([]);
await seedTemplates(prisma, testTemplates);
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('GITHUB_TOKEN');
expect(env[0].required).toBe(true);
});
it('accepts custom server list', async () => {
const custom = [
it('accepts custom template list', async () => {
const custom: SeedTemplate[] = [
{
name: 'custom-server',
description: 'Custom test server',
name: 'custom-template',
version: '2.0.0',
description: 'Custom test template',
packageName: '@test/custom',
transport: 'STDIO' as const,
repositoryUrl: 'https://example.com',
transport: 'STDIO',
env: [],
},
];
const count = await seedMcpServers(prisma, custom);
const count = await seedTemplates(prisma, custom);
expect(count).toBe(1);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('custom-server');
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});