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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -4,3 +4,4 @@ export { loadHttpConfig } from './config.js';
|
||||
export type { HttpConfig } from './config.js';
|
||||
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
|
||||
export { registerProxyRoutes } from './routes/proxy.js';
|
||||
export { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
|
||||
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Streamable HTTP MCP protocol endpoint.
|
||||
*
|
||||
* Exposes the McpRouter over HTTP at /mcp so Claude Code can connect
|
||||
* via `{ "type": "http", "url": "http://localhost:3200/mcp" }` in .mcp.json.
|
||||
*
|
||||
* Each client session gets its own StreamableHTTPServerTransport, but all
|
||||
* share the same McpRouter (and therefore the same upstream connections).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { JsonRpcRequest } from '../types.js';
|
||||
|
||||
interface SessionEntry {
|
||||
transport: StreamableHTTPServerTransport;
|
||||
}
|
||||
|
||||
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
|
||||
const sessions = new Map<string, SessionEntry>();
|
||||
|
||||
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
|
||||
app.post('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
// Existing session
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
// Fastify must not send its own response — the transport already did
|
||||
reply.hijack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId && !sessions.has(sessionId)) {
|
||||
// Unknown session
|
||||
reply.code(404).send({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// New session — no session ID header
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
sessions.set(id, { transport });
|
||||
},
|
||||
});
|
||||
|
||||
// Wire transport messages to the router
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
// The transport sends us JSON-RPC messages; route them through McpRouter
|
||||
if ('method' in message && 'id' in message) {
|
||||
const response = await router.route(message as unknown as JsonRpcRequest);
|
||||
await transport.send(response as unknown as JSONRPCMessage);
|
||||
}
|
||||
// Notifications (no id) are ignored — router doesn't handle inbound notifications
|
||||
};
|
||||
|
||||
transport.onclose = () => {
|
||||
const id = transport.sessionId;
|
||||
if (id) {
|
||||
sessions.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// GET /mcp — SSE stream for server-initiated notifications
|
||||
app.get('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
reply.hijack();
|
||||
});
|
||||
|
||||
// DELETE /mcp — Session cleanup
|
||||
app.delete('/mcp', async (request, reply) => {
|
||||
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
if (!sessionId || !sessions.has(sessionId)) {
|
||||
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessions.get(sessionId)!;
|
||||
await session.transport.handleRequest(request.raw, reply.raw);
|
||||
sessions.delete(sessionId);
|
||||
reply.hijack();
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { APP_VERSION } from '@mcpctl/shared';
|
||||
import type { HttpConfig } from './config.js';
|
||||
import { McpdClient } from './mcpd-client.js';
|
||||
import { registerProxyRoutes } from './routes/proxy.js';
|
||||
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||
import type { McpRouter } from '../router.js';
|
||||
import type { HealthMonitor } from '../health.js';
|
||||
import type { TieredHealthMonitor } from '../health/tiered.js';
|
||||
@@ -81,5 +82,8 @@ export async function createHttpServer(
|
||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||
registerProxyRoutes(app, mcpdClient);
|
||||
|
||||
// Streamable HTTP MCP protocol endpoint at /mcp
|
||||
registerMcpEndpoint(app, deps.router);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,10 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
|
||||
}
|
||||
|
||||
// Run when executed directly
|
||||
const isMain = process.argv[1]?.endsWith('main.js') || process.argv[1]?.endsWith('main.ts');
|
||||
const isMain =
|
||||
process.argv[1]?.endsWith('main.js') ||
|
||||
process.argv[1]?.endsWith('main.ts') ||
|
||||
process.argv[1]?.endsWith('mcpctl-local');
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`Fatal: ${err}\n`);
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
ProfileRegistry,
|
||||
defaultRegistry,
|
||||
profileTemplateSchema,
|
||||
validateTemplate,
|
||||
getMissingEnvVars,
|
||||
instantiateProfile,
|
||||
generateMcpJsonEntry,
|
||||
filesystemTemplate,
|
||||
githubTemplate,
|
||||
postgresTemplate,
|
||||
slackTemplate,
|
||||
memoryTemplate,
|
||||
fetchTemplate,
|
||||
} from '../src/profiles/index.js';
|
||||
|
||||
const allTemplates = [
|
||||
filesystemTemplate,
|
||||
githubTemplate,
|
||||
postgresTemplate,
|
||||
slackTemplate,
|
||||
memoryTemplate,
|
||||
fetchTemplate,
|
||||
];
|
||||
|
||||
describe('ProfileTemplate schema', () => {
|
||||
it.each(allTemplates)('validates $id template', (template) => {
|
||||
const result = profileTemplateSchema.safeParse(template);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects template with missing required fields', () => {
|
||||
const result = profileTemplateSchema.safeParse({ id: 'x' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects template with invalid id format', () => {
|
||||
const result = profileTemplateSchema.safeParse({
|
||||
...filesystemTemplate,
|
||||
id: 'Invalid ID!',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects template with invalid category', () => {
|
||||
const result = profileTemplateSchema.safeParse({
|
||||
...filesystemTemplate,
|
||||
category: 'nonexistent',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProfileRegistry', () => {
|
||||
it('default registry contains all builtin templates', () => {
|
||||
const ids = defaultRegistry.getAll().map((t) => t.id);
|
||||
expect(ids).toContain('filesystem');
|
||||
expect(ids).toContain('github');
|
||||
expect(ids).toContain('postgres');
|
||||
expect(ids).toContain('slack');
|
||||
expect(ids).toContain('memory');
|
||||
expect(ids).toContain('fetch');
|
||||
});
|
||||
|
||||
it('has no duplicate IDs', () => {
|
||||
const ids = defaultRegistry.getAll().map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('getById returns correct template', () => {
|
||||
expect(defaultRegistry.getById('github')).toBe(githubTemplate);
|
||||
expect(defaultRegistry.getById('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getByCategory filters correctly', () => {
|
||||
const integrations = defaultRegistry.getByCategory('integration');
|
||||
expect(integrations.map((t) => t.id)).toEqual(
|
||||
expect.arrayContaining(['github', 'slack']),
|
||||
);
|
||||
for (const t of integrations) {
|
||||
expect(t.category).toBe('integration');
|
||||
}
|
||||
});
|
||||
|
||||
it('getCategories returns unique categories', () => {
|
||||
const cats = defaultRegistry.getCategories();
|
||||
expect(cats.length).toBeGreaterThan(0);
|
||||
expect(new Set(cats).size).toBe(cats.length);
|
||||
});
|
||||
|
||||
it('search finds by name', () => {
|
||||
const results = defaultRegistry.search('git');
|
||||
expect(results.some((t) => t.id === 'github')).toBe(true);
|
||||
});
|
||||
|
||||
it('search finds by description', () => {
|
||||
const results = defaultRegistry.search('knowledge graph');
|
||||
expect(results.some((t) => t.id === 'memory')).toBe(true);
|
||||
});
|
||||
|
||||
it('search returns empty for no match', () => {
|
||||
expect(defaultRegistry.search('zzzznotfound')).toEqual([]);
|
||||
});
|
||||
|
||||
it('register adds a custom template', () => {
|
||||
const registry = new ProfileRegistry([]);
|
||||
registry.register(filesystemTemplate);
|
||||
expect(registry.has('filesystem')).toBe(true);
|
||||
expect(registry.getAll()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplate', () => {
|
||||
it('returns success for valid template', () => {
|
||||
const result = validateTemplate(filesystemTemplate);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns errors for invalid template', () => {
|
||||
const result = validateTemplate({ id: '' });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMissingEnvVars', () => {
|
||||
it('returns empty for template with no required env vars', () => {
|
||||
expect(getMissingEnvVars(filesystemTemplate, {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns missing vars for github template', () => {
|
||||
const missing = getMissingEnvVars(githubTemplate, {});
|
||||
expect(missing).toContain('GITHUB_PERSONAL_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('returns empty when all vars provided', () => {
|
||||
const missing = getMissingEnvVars(githubTemplate, {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_xxx',
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instantiateProfile', () => {
|
||||
it('creates profile from template without env vars', () => {
|
||||
const profile = instantiateProfile(filesystemTemplate, {});
|
||||
expect(profile.name).toBe('filesystem');
|
||||
expect(profile.templateId).toBe('filesystem');
|
||||
expect(profile.command).toBe('npx');
|
||||
expect(profile.args).toEqual(['-y', '@modelcontextprotocol/server-filesystem']);
|
||||
expect(profile.env).toEqual({});
|
||||
});
|
||||
|
||||
it('creates profile with env vars', () => {
|
||||
const profile = instantiateProfile(githubTemplate, {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_test123',
|
||||
});
|
||||
expect(profile.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe('ghp_test123');
|
||||
});
|
||||
|
||||
it('throws on missing required env vars', () => {
|
||||
expect(() => instantiateProfile(githubTemplate, {})).toThrow(
|
||||
'Missing required environment variables',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes optional env vars when provided', () => {
|
||||
const profile = instantiateProfile(filesystemTemplate, {
|
||||
SOME_OPTIONAL: 'value',
|
||||
});
|
||||
// Optional vars not in template are not included
|
||||
expect(profile.env).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMcpJsonEntry', () => {
|
||||
it('generates valid .mcp.json entry', () => {
|
||||
const profile = instantiateProfile(githubTemplate, {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc',
|
||||
});
|
||||
const entry = generateMcpJsonEntry(profile);
|
||||
expect(entry).toEqual({
|
||||
github: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('generates entry with empty env for no-env template', () => {
|
||||
const profile = instantiateProfile(memoryTemplate, {});
|
||||
const entry = generateMcpJsonEntry(profile);
|
||||
expect(entry).toEqual({
|
||||
memory: {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-memory'],
|
||||
env: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user