feat: MCP healthcheck probes + new templates #9
@@ -288,7 +288,7 @@ update_stack() {
|
||||
"env": $env,
|
||||
"stackFileContent": $stackFileContent,
|
||||
"prune": true,
|
||||
"pullImage": false
|
||||
"pullImage": true
|
||||
}')
|
||||
|
||||
local response
|
||||
|
||||
@@ -4,6 +4,14 @@ import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
const HealthCheckSchema = z.object({
|
||||
tool: z.string().min(1),
|
||||
arguments: z.record(z.unknown()).default({}),
|
||||
intervalSeconds: z.number().int().min(5).max(3600).default(60),
|
||||
timeoutSeconds: z.number().int().min(1).max(120).default(10),
|
||||
failureThreshold: z.number().int().min(1).max(20).default(3),
|
||||
});
|
||||
|
||||
const ServerEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string().optional(),
|
||||
@@ -24,6 +32,7 @@ const ServerSpecSchema = z.object({
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
const SecretSpecSchema = z.object({
|
||||
@@ -51,6 +60,7 @@ const TemplateSpecSchema = z.object({
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
const ProjectSpecSchema = z.object({
|
||||
|
||||
@@ -50,6 +50,19 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
lines.push('Health Check:');
|
||||
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
|
||||
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
|
||||
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
|
||||
}
|
||||
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
|
||||
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
|
||||
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${server.id}`);
|
||||
@@ -67,6 +80,16 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
|
||||
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
|
||||
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
|
||||
|
||||
// Health section
|
||||
const healthStatus = instance.healthStatus as string | null;
|
||||
const lastHealthCheck = instance.lastHealthCheck as string | null;
|
||||
if (healthStatus || lastHealthCheck) {
|
||||
lines.push('');
|
||||
lines.push('Health:');
|
||||
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
|
||||
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
|
||||
}
|
||||
|
||||
const metadata = instance.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
lines.push('');
|
||||
@@ -88,6 +111,19 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
|
||||
}
|
||||
}
|
||||
|
||||
// Events section (k8s-style)
|
||||
const events = instance.events as Array<{ timestamp: string; type: string; message: string }> | undefined;
|
||||
if (events && events.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Events:');
|
||||
const tsW = 26;
|
||||
const typeW = 10;
|
||||
lines.push(` ${'TIMESTAMP'.padEnd(tsW)}${'TYPE'.padEnd(typeW)}MESSAGE`);
|
||||
for (const ev of events) {
|
||||
lines.push(` ${(ev.timestamp ?? '').padEnd(tsW)}${(ev.type ?? '').padEnd(typeW)}${ev.message ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(` ${pad('ID:', 12)}${instance.id}`);
|
||||
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
|
||||
@@ -177,6 +213,19 @@ function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
lines.push('Health Check:');
|
||||
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
|
||||
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
|
||||
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
|
||||
}
|
||||
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
|
||||
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
|
||||
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Usage:');
|
||||
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
|
||||
|
||||
@@ -45,6 +45,7 @@ interface InstanceRow {
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
port: number | null;
|
||||
healthStatus: string | null;
|
||||
}
|
||||
|
||||
const serverColumns: Column<ServerRow>[] = [
|
||||
@@ -78,6 +79,7 @@ const templateColumns: Column<TemplateRow>[] = [
|
||||
|
||||
const instanceColumns: Column<InstanceRow>[] = [
|
||||
{ header: 'STATUS', key: 'status', width: 10 },
|
||||
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
|
||||
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
|
||||
|
||||
@@ -62,6 +62,7 @@ model McpServer {
|
||||
containerPort Int?
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -96,6 +97,7 @@ model McpTemplate {
|
||||
containerPort Int?
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -141,6 +143,9 @@ model McpInstance {
|
||||
status InstanceStatus @default(STOPPED)
|
||||
port Int?
|
||||
metadata Json @default("{}")
|
||||
healthStatus String?
|
||||
lastHealthCheck DateTime?
|
||||
events Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -15,4 +15,4 @@ export type {
|
||||
} from '@prisma/client';
|
||||
|
||||
export { seedTemplates } from './seed/index.js';
|
||||
export type { SeedTemplate, TemplateEnvEntry } from './seed/index.js';
|
||||
export type { SeedTemplate, TemplateEnvEntry, HealthCheckSpec } from './seed/index.js';
|
||||
|
||||
@@ -7,6 +7,14 @@ export interface TemplateEnvEntry {
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckSpec {
|
||||
tool: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
intervalSeconds?: number;
|
||||
timeoutSeconds?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SeedTemplate {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -20,6 +28,7 @@ export interface SeedTemplate {
|
||||
containerPort?: number;
|
||||
replicas?: number;
|
||||
env?: TemplateEnvEntry[];
|
||||
healthCheck?: HealthCheckSpec;
|
||||
}
|
||||
|
||||
export async function seedTemplates(
|
||||
@@ -43,6 +52,7 @@ export async function seedTemplates(
|
||||
containerPort: tpl.containerPort ?? null,
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
name: tpl.name,
|
||||
@@ -57,6 +67,7 @@ export async function seedTemplates(
|
||||
containerPort: tpl.containerPort ?? null,
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
upserted++;
|
||||
|
||||
@@ -69,6 +69,7 @@ async function main(): Promise<void> {
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
|
||||
};
|
||||
});
|
||||
await seedTemplates(prisma, templates);
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface IMcpInstanceRepository {
|
||||
findById(id: string): Promise<McpInstance | null>;
|
||||
findByContainerId(containerId: string): Promise<McpInstance | null>;
|
||||
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: InstanceStatus,
|
||||
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
|
||||
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] },
|
||||
): Promise<McpInstance> {
|
||||
const updateData: Prisma.McpInstanceUpdateInput = {
|
||||
status,
|
||||
@@ -59,6 +59,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||
if (fields?.metadata !== undefined) {
|
||||
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
|
||||
}
|
||||
if (fields?.healthStatus !== undefined) {
|
||||
updateData.healthStatus = fields.healthStatus;
|
||||
}
|
||||
if (fields?.lastHealthCheck !== undefined) {
|
||||
updateData.lastHealthCheck = fields.lastHealthCheck;
|
||||
}
|
||||
if (fields?.events !== undefined) {
|
||||
updateData.events = fields.events as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
return this.prisma.mcpInstance.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
|
||||
@@ -31,6 +31,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
env: data.env,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||
if (data.env !== undefined) updateData['env'] = data.env;
|
||||
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -67,6 +68,7 @@ export class TemplateRepository implements ITemplateRepository {
|
||||
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;
|
||||
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpTemplate.update({
|
||||
where: { id },
|
||||
|
||||
@@ -24,6 +24,7 @@ function loadTemplatesFromDir(dir: string): SeedTemplate[] {
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HealthCheckSchema } from './template.schema.js';
|
||||
|
||||
const SecretRefSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -30,6 +31,7 @@ export const CreateMcpServerSchema = z.object({
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateMcpServerSchema = z.object({
|
||||
@@ -43,6 +45,7 @@ export const UpdateMcpServerSchema = z.object({
|
||||
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
replicas: z.number().int().min(0).max(10).optional(),
|
||||
env: z.array(ServerEnvEntrySchema).optional(),
|
||||
healthCheck: HealthCheckSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||
|
||||
@@ -7,6 +7,16 @@ const TemplateEnvEntrySchema = z.object({
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
export const HealthCheckSchema = z.object({
|
||||
tool: z.string().min(1),
|
||||
arguments: z.record(z.unknown()).default({}),
|
||||
intervalSeconds: z.number().int().min(5).max(3600).default(60),
|
||||
timeoutSeconds: z.number().int().min(1).max(120).default(10),
|
||||
failureThreshold: z.number().int().min(1).max(20).default(3),
|
||||
});
|
||||
|
||||
export type HealthCheckInput = z.infer<typeof HealthCheckSchema>;
|
||||
|
||||
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'),
|
||||
@@ -20,6 +30,7 @@ export const CreateTemplateSchema = z.object({
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });
|
||||
|
||||
@@ -4,6 +4,10 @@ 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
|
||||
healthCheck:
|
||||
tool: search_repositories
|
||||
arguments:
|
||||
query: "test"
|
||||
env:
|
||||
- name: GITHUB_TOKEN
|
||||
description: Personal access token with repo scope
|
||||
|
||||
16
templates/grafana.yaml
Normal file
16
templates/grafana.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: grafana
|
||||
version: "1.0.0"
|
||||
description: Grafana MCP server for dashboards, datasources, and alerts
|
||||
packageName: "@leval/mcp-grafana"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/levalhq/mcp-grafana
|
||||
healthCheck:
|
||||
tool: list_datasources
|
||||
arguments: {}
|
||||
env:
|
||||
- name: GRAFANA_URL
|
||||
description: Grafana instance URL (e.g. https://grafana.example.com)
|
||||
required: true
|
||||
- name: GRAFANA_SERVICE_ACCOUNT_TOKEN
|
||||
description: Grafana service account token (glsa_...)
|
||||
required: true
|
||||
16
templates/home-assistant.yaml
Normal file
16
templates/home-assistant.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: home-assistant
|
||||
version: "1.0.0"
|
||||
description: Home Assistant MCP server for smart home control and entity management
|
||||
packageName: "home-assistant-mcp-server"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/tevonsb/homeassistant-mcp
|
||||
healthCheck:
|
||||
tool: get_entities
|
||||
arguments: {}
|
||||
env:
|
||||
- name: HASS_URL
|
||||
description: Home Assistant instance URL (e.g. http://homeassistant.local:8123)
|
||||
required: true
|
||||
- name: HASS_TOKEN
|
||||
description: Home Assistant long-lived access token
|
||||
required: true
|
||||
@@ -4,6 +4,11 @@ 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
|
||||
healthCheck:
|
||||
tool: search_issues
|
||||
arguments:
|
||||
jql: "created >= -1d"
|
||||
maxResults: 1
|
||||
env:
|
||||
- name: JIRA_URL
|
||||
description: Jira instance URL (e.g. https://company.atlassian.net)
|
||||
|
||||
16
templates/node-red.yaml
Normal file
16
templates/node-red.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
name: node-red
|
||||
version: "1.0.0"
|
||||
description: Node-RED MCP server for flow management and automation
|
||||
packageName: "mcp-node-red"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/fx/mcp-node-red
|
||||
healthCheck:
|
||||
tool: get_settings
|
||||
arguments: {}
|
||||
env:
|
||||
- name: NODE_RED_URL
|
||||
description: Node-RED instance URL (e.g. http://nodered.local:1880)
|
||||
required: true
|
||||
- name: NODE_RED_TOKEN
|
||||
description: Node-RED access token (optional if no auth)
|
||||
required: false
|
||||
@@ -4,6 +4,10 @@ description: PostgreSQL MCP server for database queries and schema inspection
|
||||
packageName: "@anthropic/postgres-mcp"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/postgres
|
||||
healthCheck:
|
||||
tool: query
|
||||
arguments:
|
||||
sql: "SELECT 1"
|
||||
env:
|
||||
- name: POSTGRES_CONNECTION_STRING
|
||||
description: PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db)
|
||||
|
||||
@@ -4,6 +4,9 @@ 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
|
||||
healthCheck:
|
||||
tool: list_channels
|
||||
arguments: {}
|
||||
env:
|
||||
- name: SLACK_BOT_TOKEN
|
||||
description: Slack bot token (xoxb-...)
|
||||
|
||||
Reference in New Issue
Block a user