From c97219f85eba5a2b5354de5c8f7bb6057c56d95a Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 22:48:59 +0000 Subject: [PATCH] feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red) - Add healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold) - Add healthStatus, lastHealthCheck, events fields to instances - Create grafana, home-assistant, node-red templates with healthcheck probes - Add healthcheck probes to existing templates (github, slack, postgres, jira) - Show HEALTH column in `get instances` and Events section in `describe instance` - Display healthCheck details in `describe server` and `describe template` - Schema + storage + display only; actual probe runner is future work Co-Authored-By: Claude Opus 4.6 --- deploy.sh | 2 +- src/cli/src/commands/apply.ts | 10 ++++ src/cli/src/commands/describe.ts | 49 +++++++++++++++++++ src/cli/src/commands/get.ts | 2 + src/db/prisma/schema.prisma | 13 +++-- src/db/src/index.ts | 2 +- src/db/src/seed/index.ts | 11 +++++ src/mcpd/src/main.ts | 1 + src/mcpd/src/repositories/interfaces.ts | 2 +- .../repositories/mcp-instance.repository.ts | 11 ++++- .../src/repositories/mcp-server.repository.ts | 2 + .../src/repositories/template.repository.ts | 2 + src/mcpd/src/seed-runner.ts | 1 + src/mcpd/src/validation/mcp-server.schema.ts | 3 ++ src/mcpd/src/validation/template.schema.ts | 11 +++++ templates/github.yaml | 4 ++ templates/grafana.yaml | 16 ++++++ templates/home-assistant.yaml | 16 ++++++ templates/jira.yaml | 5 ++ templates/node-red.yaml | 16 ++++++ templates/postgres.yaml | 4 ++ templates/slack.yaml | 3 ++ 22 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 templates/grafana.yaml create mode 100644 templates/home-assistant.yaml create mode 100644 templates/node-red.yaml diff --git a/deploy.sh b/deploy.sh index a20f77c..1008d97 100755 --- a/deploy.sh +++ b/deploy.sh @@ -288,7 +288,7 @@ update_stack() { "env": $env, "stackFileContent": $stackFileContent, "prune": true, - "pullImage": false + "pullImage": true }') local response diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index a47a168..cb1bd40 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -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({ diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 4250b5f..f9acba9 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -50,6 +50,19 @@ function formatServerDetail(server: Record): string { } } + const hc = server.healthCheck as { tool: string; arguments?: Record; 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, 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 | undefined; if (metadata && Object.keys(metadata).length > 0) { lines.push(''); @@ -88,6 +111,19 @@ function formatInstanceDetail(instance: Record, 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 { } } + const hc = template.healthCheck as { tool: string; arguments?: Record; 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}`); diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index ba0ec29..624ed93 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -45,6 +45,7 @@ interface InstanceRow { status: string; containerId: string | null; port: number | null; + healthStatus: string | null; } const serverColumns: Column[] = [ @@ -78,6 +79,7 @@ const templateColumns: Column[] = [ const instanceColumns: Column[] = [ { 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 }, diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index bd9da5e..be685e6 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -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 @@ -140,10 +142,13 @@ model McpInstance { containerId String? status InstanceStatus @default(STOPPED) port Int? - metadata Json @default("{}") - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + metadata Json @default("{}") + healthStatus String? + lastHealthCheck DateTime? + events Json @default("[]") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) diff --git a/src/db/src/index.ts b/src/db/src/index.ts index c991d1f..2ea54fe 100644 --- a/src/db/src/index.ts +++ b/src/db/src/index.ts @@ -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'; diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index 2e6a872..6af9046 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -7,6 +7,14 @@ export interface TemplateEnvEntry { defaultValue?: string; } +export interface HealthCheckSpec { + tool: string; + arguments?: Record; + 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++; diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 7306d7a..629910e 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -69,6 +69,7 @@ async function main(): Promise { transport: parsed.transport ?? 'STDIO', version: parsed.version ?? '1.0.0', description: parsed.description ?? '', + ...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}), }; }); await seedTemplates(prisma, templates); diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index fb79076..d79772f 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -16,7 +16,7 @@ export interface IMcpInstanceRepository { findById(id: string): Promise; findByContainerId(containerId: string): Promise; create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record }): Promise; - updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record }): Promise; + updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise; delete(id: string): Promise; } diff --git a/src/mcpd/src/repositories/mcp-instance.repository.ts b/src/mcpd/src/repositories/mcp-instance.repository.ts index 5472fd2..0a6aa13 100644 --- a/src/mcpd/src/repositories/mcp-instance.repository.ts +++ b/src/mcpd/src/repositories/mcp-instance.repository.ts @@ -44,7 +44,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository { async updateStatus( id: string, status: InstanceStatus, - fields?: { containerId?: string; port?: number; metadata?: Record }, + fields?: { containerId?: string; port?: number; metadata?: Record; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }, ): Promise { 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, diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts index e4cd82b..7443c41 100644 --- a/src/mcpd/src/repositories/mcp-server.repository.ts +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -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 }); } diff --git a/src/mcpd/src/repositories/template.repository.ts b/src/mcpd/src/repositories/template.repository.ts index be15015..4cf88b0 100644 --- a/src/mcpd/src/repositories/template.repository.ts +++ b/src/mcpd/src/repositories/template.repository.ts @@ -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 }, diff --git a/src/mcpd/src/seed-runner.ts b/src/mcpd/src/seed-runner.ts index 75d03fc..1e6cf89 100644 --- a/src/mcpd/src/seed-runner.ts +++ b/src/mcpd/src/seed-runner.ts @@ -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 } : {}), }); } } diff --git a/src/mcpd/src/validation/mcp-server.schema.ts b/src/mcpd/src/validation/mcp-server.schema.ts index 0865424..4df7a8b 100644 --- a/src/mcpd/src/validation/mcp-server.schema.ts +++ b/src/mcpd/src/validation/mcp-server.schema.ts @@ -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; diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 7d8a91a..1d9e298 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -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; + 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 }); diff --git a/templates/github.yaml b/templates/github.yaml index ec9ffdc..8290fab 100644 --- a/templates/github.yaml +++ b/templates/github.yaml @@ -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 diff --git a/templates/grafana.yaml b/templates/grafana.yaml new file mode 100644 index 0000000..2bf6992 --- /dev/null +++ b/templates/grafana.yaml @@ -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 diff --git a/templates/home-assistant.yaml b/templates/home-assistant.yaml new file mode 100644 index 0000000..957fda8 --- /dev/null +++ b/templates/home-assistant.yaml @@ -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 diff --git a/templates/jira.yaml b/templates/jira.yaml index 2844720..544b3fc 100644 --- a/templates/jira.yaml +++ b/templates/jira.yaml @@ -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) diff --git a/templates/node-red.yaml b/templates/node-red.yaml new file mode 100644 index 0000000..d81b749 --- /dev/null +++ b/templates/node-red.yaml @@ -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 diff --git a/templates/postgres.yaml b/templates/postgres.yaml index d1208a4..ce3b3be 100644 --- a/templates/postgres.yaml +++ b/templates/postgres.yaml @@ -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) diff --git a/templates/slack.yaml b/templates/slack.yaml index 20c5ec9..6c15f8d 100644 --- a/templates/slack.yaml +++ b/templates/slack.yaml @@ -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-...) -- 2.49.1