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:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user