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:
59
src/cli/tests/health-check-opts.test.ts
Normal file
59
src/cli/tests/health-check-opts.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user