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

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