Merge pull request 'feat: MCP healthcheck probes + new templates' (#9) from feat/healthcheck-probes into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

This commit was merged in pull request #9.
This commit is contained in:
2026-02-22 22:50:10 +00:00
22 changed files with 178 additions and 8 deletions

View File

@@ -288,7 +288,7 @@ update_stack() {
"env": $env,
"stackFileContent": $stackFileContent,
"prune": true,
"pullImage": false
"pullImage": true
}')
local response

View File

@@ -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({

View File

@@ -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}`);

View File

@@ -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 },

View File

@@ -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

View File

@@ -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';

View File

@@ -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++;

View File

@@ -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);

View File

@@ -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>;
}

View File

@@ -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,

View File

@@ -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 });
}

View File

@@ -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 },

View File

@@ -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 } : {}),
});
}
}

View File

@@ -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>;

View File

@@ -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 });

View File

@@ -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
View 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

View 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

View File

@@ -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
View 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

View File

@@ -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)

View File

@@ -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-...)