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
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:
@@ -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),
|
||||
|
||||
@@ -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(' | ||||