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:
@@ -23,11 +23,13 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"dockerode": "^4.0.9",
|
||||
"fastify": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedMcpServers } from '@mcpctl/db';
|
||||
import yaml from 'js-yaml';
|
||||
import { seedTemplates } from '@mcpctl/db';
|
||||
import type { SeedTemplate } from '@mcpctl/db';
|
||||
import { loadConfigFromEnv } from './config/index.js';
|
||||
import { createServer } from './server.js';
|
||||
import { setupGracefulShutdown } from './utils/index.js';
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
McpInstanceRepository,
|
||||
ProjectRepository,
|
||||
AuditLogRepository,
|
||||
TemplateRepository,
|
||||
} from './repositories/index.js';
|
||||
import {
|
||||
McpServerService,
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
RestoreService,
|
||||
AuthService,
|
||||
McpProxyService,
|
||||
TemplateService,
|
||||
} from './services/index.js';
|
||||
import {
|
||||
registerMcpServerRoutes,
|
||||
@@ -34,6 +40,7 @@ import {
|
||||
registerBackupRoutes,
|
||||
registerAuthRoutes,
|
||||
registerMcpProxyRoutes,
|
||||
registerTemplateRoutes,
|
||||
} from './routes/index.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -45,8 +52,26 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await prisma.$connect();
|
||||
|
||||
// Seed default servers (upsert, safe to repeat)
|
||||
await seedMcpServers(prisma);
|
||||
// Seed templates from YAML files
|
||||
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
|
||||
const templateFiles = (() => {
|
||||
try {
|
||||
return readdirSync(templatesDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const templates: SeedTemplate[] = templateFiles.map((f) => {
|
||||
const content = readFileSync(join(templatesDir, f), 'utf-8');
|
||||
const parsed = yaml.load(content) as SeedTemplate;
|
||||
return {
|
||||
...parsed,
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
};
|
||||
});
|
||||
await seedTemplates(prisma, templates);
|
||||
|
||||
// Repositories
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
@@ -54,6 +79,7 @@ async function main(): Promise<void> {
|
||||
const instanceRepo = new McpInstanceRepository(prisma);
|
||||
const projectRepo = new ProjectRepository(prisma);
|
||||
const auditLogRepo = new AuditLogRepository(prisma);
|
||||
const templateRepo = new TemplateRepository(prisma);
|
||||
|
||||
// Orchestrator
|
||||
const orchestrator = new DockerContainerManager();
|
||||
@@ -70,6 +96,7 @@ async function main(): Promise<void> {
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const templateService = new TemplateService(templateRepo);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
||||
|
||||
// Server
|
||||
@@ -88,6 +115,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Routes
|
||||
registerMcpServerRoutes(app, serverService, instanceService);
|
||||
registerTemplateRoutes(app, templateService);
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
|
||||
@@ -5,3 +5,5 @@ export type { IProjectRepository } from './project.repository.js';
|
||||
export { ProjectRepository } from './project.repository.js';
|
||||
export { McpInstanceRepository } from './mcp-instance.repository.js';
|
||||
export { AuditLogRepository } from './audit-log.repository.js';
|
||||
export type { ITemplateRepository } from './template.repository.js';
|
||||
export { TemplateRepository } from './template.repository.js';
|
||||
|
||||
80
src/mcpd/src/repositories/template.repository.ts
Normal file
80
src/mcpd/src/repositories/template.repository.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client';
|
||||
import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js';
|
||||
|
||||
export interface ITemplateRepository {
|
||||
findAll(): Promise<McpTemplate[]>;
|
||||
findById(id: string): Promise<McpTemplate | null>;
|
||||
findByName(name: string): Promise<McpTemplate | null>;
|
||||
search(pattern: string): Promise<McpTemplate[]>;
|
||||
create(data: CreateTemplateInput): Promise<McpTemplate>;
|
||||
update(id: string, data: UpdateTemplateInput): Promise<McpTemplate>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class TemplateRepository implements ITemplateRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<McpTemplate[]> {
|
||||
return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<McpTemplate | null> {
|
||||
return this.prisma.mcpTemplate.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<McpTemplate | null> {
|
||||
return this.prisma.mcpTemplate.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async search(pattern: string): Promise<McpTemplate[]> {
|
||||
// Convert glob * to SQL %
|
||||
const sqlPattern = pattern.replace(/\*/g, '%');
|
||||
return this.prisma.mcpTemplate.findMany({
|
||||
where: { name: { contains: sqlPattern.replace(/%/g, ''), mode: 'insensitive' } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateTemplateInput): Promise<McpTemplate> {
|
||||
return this.prisma.mcpTemplate.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
version: data.version,
|
||||
description: data.description,
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: data.dockerImage ?? null,
|
||||
transport: data.transport,
|
||||
repositoryUrl: data.repositoryUrl ?? null,
|
||||
externalUrl: data.externalUrl ?? null,
|
||||
command: (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTemplateInput): Promise<McpTemplate> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.version !== undefined) updateData.version = data.version;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.packageName !== undefined) updateData.packageName = data.packageName;
|
||||
if (data.dockerImage !== undefined) updateData.dockerImage = data.dockerImage;
|
||||
if (data.transport !== undefined) updateData.transport = data.transport;
|
||||
if (data.repositoryUrl !== undefined) updateData.repositoryUrl = data.repositoryUrl;
|
||||
if (data.externalUrl !== undefined) updateData.externalUrl = data.externalUrl;
|
||||
if (data.command !== undefined) updateData.command = (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
|
||||
if (data.replicas !== undefined) updateData.replicas = data.replicas;
|
||||
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpTemplate.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.mcpTemplate.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export { registerAuthRoutes } from './auth.js';
|
||||
export type { AuthRouteDeps } from './auth.js';
|
||||
export { registerMcpProxyRoutes } from './mcp-proxy.js';
|
||||
export type { McpProxyRouteDeps } from './mcp-proxy.js';
|
||||
export { registerTemplateRoutes } from './templates.js';
|
||||
|
||||
31
src/mcpd/src/routes/templates.ts
Normal file
31
src/mcpd/src/routes/templates.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { TemplateService } from '../services/template.service.js';
|
||||
|
||||
export function registerTemplateRoutes(
|
||||
app: FastifyInstance,
|
||||
service: TemplateService,
|
||||
): void {
|
||||
app.get<{ Querystring: { name?: string } }>('/api/v1/templates', async (request) => {
|
||||
const namePattern = request.query.name;
|
||||
return service.list(namePattern);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/templates', async (request, reply) => {
|
||||
const template = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return template;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/templates/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,44 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedMcpServers } from '@mcpctl/db';
|
||||
import yaml from 'js-yaml';
|
||||
import { seedTemplates } from '@mcpctl/db';
|
||||
import type { SeedTemplate } from '@mcpctl/db';
|
||||
|
||||
function loadTemplatesFromDir(dir: string): SeedTemplate[] {
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
} catch {
|
||||
console.warn(`Templates directory not found: ${dir}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const templates: SeedTemplate[] = [];
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(dir, file), 'utf-8');
|
||||
const parsed = yaml.load(content) as SeedTemplate;
|
||||
if (parsed?.name) {
|
||||
templates.push({
|
||||
...parsed,
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const count = await seedMcpServers(prisma);
|
||||
console.log(`Seeded ${count} MCP servers`);
|
||||
// Look for templates in common locations
|
||||
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
|
||||
const templates = loadTemplatesFromDir(templatesDir);
|
||||
const count = await seedTemplates(prisma, templates);
|
||||
console.log(`Seeded ${count} templates from ${templatesDir}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -24,3 +24,4 @@ export { AuthService, AuthenticationError } from './auth.service.js';
|
||||
export type { LoginResult } from './auth.service.js';
|
||||
export { McpProxyService } from './mcp-proxy-service.js';
|
||||
export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
|
||||
export { TemplateService } from './template.service.js';
|
||||
|
||||
53
src/mcpd/src/services/template.service.ts
Normal file
53
src/mcpd/src/services/template.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { McpTemplate } from '@prisma/client';
|
||||
import type { ITemplateRepository } from '../repositories/template.repository.js';
|
||||
import { CreateTemplateSchema, UpdateTemplateSchema } from '../validation/template.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
|
||||
export class TemplateService {
|
||||
constructor(private readonly repo: ITemplateRepository) {}
|
||||
|
||||
async list(namePattern?: string): Promise<McpTemplate[]> {
|
||||
if (namePattern) {
|
||||
return this.repo.search(namePattern);
|
||||
}
|
||||
return this.repo.findAll();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<McpTemplate> {
|
||||
const template = await this.repo.findById(id);
|
||||
if (template === null) {
|
||||
throw new NotFoundError(`Template not found: ${id}`);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
async getByName(name: string): Promise<McpTemplate> {
|
||||
const template = await this.repo.findByName(name);
|
||||
if (template === null) {
|
||||
throw new NotFoundError(`Template not found: ${name}`);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
async create(input: unknown): Promise<McpTemplate> {
|
||||
const data = CreateTemplateSchema.parse(input);
|
||||
|
||||
const existing = await this.repo.findByName(data.name);
|
||||
if (existing !== null) {
|
||||
throw new ConflictError(`Template already exists: ${data.name}`);
|
||||
}
|
||||
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<McpTemplate> {
|
||||
const data = UpdateTemplateSchema.parse(input);
|
||||
await this.getById(id);
|
||||
return this.repo.update(id, data);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
}
|
||||
28
src/mcpd/src/validation/template.schema.ts
Normal file
28
src/mcpd/src/validation/template.schema.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const TemplateEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||
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([]),
|
||||
});
|
||||
|
||||
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });
|
||||
|
||||
export type CreateTemplateInput = z.infer<typeof CreateTemplateSchema>;
|
||||
export type UpdateTemplateInput = z.infer<typeof UpdateTemplateSchema>;
|
||||
@@ -3,7 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
"types": ["node", "js-yaml"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
|
||||
Reference in New Issue
Block a user