fix(health): a passing tools/list is live, not healthy
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / typecheck (pull_request) Successful in 3m15s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 5m5s
CI/CD / publish (pull_request) Has been skipped

`mcpctl get instances` showed all eight servers healthy while the UniFi one
had never once reached its controller. The default probe is `tools/list`,
which MCP servers answer from a static in-process table — no credentials, no
upstream, ~3ms. It cannot fail for any reason the user cares about, so it was
reporting `healthy` for every process that managed to start.

Split the two passes:

  healthy  — readiness: `tools/call` on `healthCheck.tool`. The upstream
             answered, so the server can actually do its job.
  live     — liveness: `tools/list` only. Process up, upstream unverified.

`live` is now the default for any server without a `healthCheck.tool`. It is
not a warning; it is an admission that nothing is watching that server. Probe
events name which probe ran and which tool ("Readiness check (list_sites)
passed"), so the events log distinguishes the two after the fact.

Also:
- `healthCheck.tool` is optional now, so the timings can be tuned without
  inventing a readiness probe.
- `create server --health-check-tool/-args/-interval/-timeout/
  -failure-threshold`, per the rule that everything applyable is a create
  flag. Merges over a `--from-template` healthCheck rather than replacing it.
- `describe instance` explains a `live` verdict instead of leaving it cryptic.
- create.ts held a raw NUL byte in a string literal, which made grep treat the
  whole file as binary and silently skip it. Escaped as `\0`.

Verified against the live fleet: with readiness probes configured, my-grafana
went unhealthy (Grafana API 403) and my-node-red degraded (connect timeout to
a Tailscale address) — both had read healthy for months.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114dg56YmVacyqhp5fitcTb
This commit is contained in:
Michal
2026-08-09 21:23:33 +01:00
parent ff0e71da05
commit b2547429ca
11 changed files with 495 additions and 9 deletions

View File

@@ -5,7 +5,8 @@ import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
/** Omit for a liveness-only probe (reports `live`); set it for readiness (`healthy`). */
tool: z.string().min(1).optional(),
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),

View File

@@ -42,6 +42,69 @@ export function buildFavouriteIndex(
return result;
}
export interface HealthCheckOpts {
healthCheckTool?: string;
healthCheckArgs?: string;
healthCheckInterval?: string;
healthCheckTimeout?: string;
healthCheckFailureThreshold?: string;
}
export interface HealthCheckSpec {
tool?: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
function parsePositiveInt(flag: string, value: string): number {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Invalid ${flag} '${value}'. Expected a positive integer.`);
}
return n;
}
/**
* Build a server `healthCheck` spec from `--health-check-*` flags, or undefined
* when none were given (so the field is only sent when intended).
*
* Mirrors the `healthCheck:` block accepted by `apply -f`, per the rule that
* everything applyable is also a create flag.
*/
export function buildHealthCheck(opts: HealthCheckOpts): HealthCheckSpec | undefined {
const { healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold } = opts;
const given = [healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold]
.some((v) => v !== undefined);
if (!given) return undefined;
if (healthCheckArgs !== undefined && healthCheckTool === undefined) {
throw new Error('--health-check-args requires --health-check-tool.');
}
const spec: HealthCheckSpec = {};
if (healthCheckTool !== undefined) spec.tool = healthCheckTool;
if (healthCheckArgs !== undefined) {
let parsed: unknown;
try {
parsed = JSON.parse(healthCheckArgs);
} catch {
throw new Error(`Invalid --health-check-args: not valid JSON. Expected a JSON object, e.g. '{"site":"default"}'.`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Invalid --health-check-args: expected a JSON object, e.g. '{"site":"default"}'.`);
}
spec.arguments = parsed as Record<string, unknown>;
}
if (healthCheckInterval !== undefined) spec.intervalSeconds = parsePositiveInt('--health-check-interval', healthCheckInterval);
if (healthCheckTimeout !== undefined) spec.timeoutSeconds = parsePositiveInt('--health-check-timeout', healthCheckTimeout);
if (healthCheckFailureThreshold !== undefined) {
spec.failureThreshold = parsePositiveInt('--health-check-failure-threshold', healthCheckFailureThreshold);
}
return spec;
}
/**
* Parse a `--ttl` value.
*
@@ -136,6 +199,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--container-port <port>', 'Container port number')
.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('--health-check-tool <tool>', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")')
.option('--health-check-args <json>', 'Readiness probe: JSON object of arguments for the probe tool')
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
.option('--force', 'Update if already exists')
@@ -218,6 +286,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
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);
// Merge over any healthCheck inherited from --from-template so partial
// flags (e.g. only --health-check-interval) tune rather than replace it.
const healthCheck = buildHealthCheck(opts as HealthCheckOpts);
if (healthCheck) {
body.healthCheck = { ...(base.healthCheck as HealthCheckSpec | undefined), ...healthCheck };
}
if (opts.env.length > 0) {
// Merge: CLI env entries override template env entries by name
const cliEnv = parseServerEnv(opts.env);
@@ -898,7 +972,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const buf = await fs.readFile(full);
// Reject non-UTF8 — v1 is text-only.
const text = buf.toString('utf-8');
if (text.includes('')) {
if (text.includes('\0')) {
throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`);
}
files[rel] = text;

View File

@@ -90,6 +90,12 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
if (healthStatus === 'live') {
lines.push(` ${pad('Probe:', 16)}liveness (tools/list) — process is up, but nothing`);
lines.push(` ${pad('', 16)}calls the server's upstream. Configure a readiness`);
lines.push(` ${pad('', 16)}probe to reach 'healthy':`);
lines.push(` ${pad('', 16)} mcpctl edit server ${server?.name ?? ''} → healthCheck.tool`);
}
}
const metadata = instance.metadata as Record<string, unknown> | undefined;

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { buildHealthCheck } from '../src/commands/create.js';
describe('buildHealthCheck — CLI flags → server healthCheck', () => {
it('returns undefined when no health-check flag is given', () => {
expect(buildHealthCheck({})).toBeUndefined();
});
it('builds a readiness probe from --health-check-tool', () => {
expect(buildHealthCheck({ healthCheckTool: 'list_sites' })).toEqual({ tool: 'list_sites' });
});
it('parses --health-check-args as a JSON object', () => {
expect(buildHealthCheck({ healthCheckTool: 'get_devices', healthCheckArgs: '{"site":"default"}' }))
.toEqual({ tool: 'get_devices', arguments: { site: 'default' } });
});
it('carries the timing flags through', () => {
expect(buildHealthCheck({
healthCheckTool: 'list_sites',
healthCheckInterval: '120',
healthCheckTimeout: '15',
healthCheckFailureThreshold: '2',
})).toEqual({
tool: 'list_sites',
intervalSeconds: 120,
timeoutSeconds: 15,
failureThreshold: 2,
});
});
it('allows tuning the liveness probe without a tool', () => {
// No `tool` → the probe stays liveness-only (reports `live`), but the
// interval is still configurable.
expect(buildHealthCheck({ healthCheckInterval: '300' })).toEqual({ intervalSeconds: 300 });
});
it('rejects --health-check-args without a tool', () => {
expect(() => buildHealthCheck({ healthCheckArgs: '{}' }))
.toThrow(/--health-check-args requires --health-check-tool/);
});
it('rejects non-JSON args', () => {
expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: 'site=default' }))
.toThrow(/not valid JSON/);
});
it('rejects JSON args that are not an object', () => {
expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: '["a"]' }))
.toThrow(/expected a JSON object/);
});
it('rejects non-positive-integer timings', () => {
expect(() => buildHealthCheck({ healthCheckInterval: '0' })).toThrow(/--health-check-interval/);
expect(() => buildHealthCheck({ healthCheckTimeout: 'abc' })).toThrow(/--health-check-timeout/);
expect(() => buildHealthCheck({ healthCheckFailureThreshold: '-1' }))
.toThrow(/--health-check-failure-threshold/);
});
});