The three gates
What each gate proves, why /healthz must not touch the backend, and why /test must.
g1 GET /healthz liveness
g2 GET /test readiness — MUST exercise the real backend
g3 tools/list the protocol works, and returns >= 1 tool
available ⟺ g1 ∧ g2 ∧ g3 ∧ toolCount > 0Source: verifi-engine/src/registry/three-gate-probe.ts.
Why three and not one
Because they are three unrelated problems, and collapsing them into "probe failed" is how
a scanner line stays green while every tool reports SKIP.
/healthzfailing is a dead container./testfailing is a container that is up but whose real backend is not.tools/listreturning zero is an MCP serving nothing.
The probe records each gate's verdict individually, with latency and a detail string, precisely so an operator can tell which of those three it is.
Gate 1 — /healthz, liveness only
@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(request: Request) -> JSONResponse:
"""Liveness ONLY. Deliberately touches no backend: if a transient tool
problem could fail this, an orchestrator would kill a container that is
perfectly alive."""
return JSONResponse({"status": "ok", "service": SERVICE, "version": VERSION})The probe requires strictly 2xx. The previous engine's readiness check counted any 4xx as ready, which is how a broken server passed preflight for the life of the product.
Both real MCPs also expose /health as an alias, purely so existing Docker/ECS
HEALTHCHECK definitions keep working. Add it if you need it; the probe does not use it.
Gate 2 — /test, readiness against the real backend
This is the gate that matters, and the one people get wrong.
The probe requires both an HTTP 2xx and a JSON body with ok: true:
const res = await http(`${base}/test`, { method: 'GET', signal });
const body = await res.json().catch(() => ({}));
return { ok: res.ok && body.ok === true, detail: body.detail ?? `status ${res.status}` };So the minimum body is:
{ "ok": true, "detail": "all required tools executable" }Return 503 as well as ok: false when unhealthy, so a caller checking only the status
code still gets the right answer. Both real MCPs do this.
"Present" is not "works"
The ASM MCP does not check $PATH. It executes each binary with a version flag,
because a truncated or wrong-architecture download resolves on $PATH and then fails on
every real call:
_VERSION_FLAG = {
"nmap": "--version",
"subfinder": "-version",
"nikto": "-Version",
"whatweb": "--version",
"feroxbuster": "--version",
}
async def _probe_tool(name: str) -> dict:
path = shutil.which(name)
if not path:
return {"present": False, "executable": False, "detail": "not on PATH"}
try:
proc = await asyncio.create_subprocess_exec(
path, _VERSION_FLAG.get(name, "--version"),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
first = out.decode("utf-8", "replace").strip().splitlines()
return {"present": True, "executable": True, "path": path,
"detail": first[0][:120] if first else "no version output"}
except asyncio.TimeoutError:
return {"present": True, "executable": False, "path": path, "detail": "version call timed out"}
except Exception as e: # a failed probe is a FAILED probe, never an absent one
return {"present": True, "executable": False, "path": path, "detail": f"{type(e).__name__}: {e}"}The docstring on that endpoint names the exact failure it exists to prevent:
The image build installs subfinder and feroxbuster from GitHub releases NON-FATALLY (
|| echo "[warn] … skipped"), so a rate-limited download produces an image that starts cleanly and reports healthy while its scanner is missing. v1 shipped exactly that shape — the infra line wired perfectly and found almost nothing because every CLI reportedSKIP: not installed.Reporting liveness while the tools are gone is how a scan runs to completion, finds nothing, and gets read as a clean bill of health.
Required versus optional
Split your backend into what makes the server decorative when missing versus what merely degrades it. The ASM MCP fails gate 2 only on the required set, and reports the rest:
REQUIRED_TOOLS = ("nmap", "subfinder")
OPTIONAL_TOOLS = ("nikto", "whatweb", "feroxbuster")
missing = [n for n in REQUIRED_TOOLS if not tools[n]["executable"]]
degraded = [n for n in OPTIONAL_TOOLS if not tools[n]["executable"]]
ok = not missing
if ok:
detail = "all required tools executable"
if degraded:
detail += f"; degraded (optional missing): {', '.join(degraded)}"
else:
detail = f"required tools unusable: {', '.join(missing)}"
return JSONResponse(
{"ok": ok, "detail": detail, "service": SERVICE, "version": VERSION, "tools": tools},
status_code=200 if ok else 503,
)Include the per-tool detail in the body. The probe stores detail, and an operator staring
at a red registry entry wants to know which binary.
What readiness means for a non-CLI backend
The rule generalises. For a web proxy, gate 2 means reaching the proxy's own API and confirming a project is loaded. For a knowledge base, it means a live retrieval returning at least one chunk against the pinned content version. In every case: can I actually do the thing I exist to do, not does my process respond.
Gate 3 — real MCP tools/list
The probe speaks the actual protocol. Three requests to POST {base}/mcp:
1. {"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"verifi-engine-probe","version":"1.0.0"}}}
2. {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} ← notification
3. {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}Every request carries accept: application/json, text/event-stream — both types, or a
streamable-http server answers 406. Any mcp-session-id response header is captured and
sent on the following requests. Responses are parsed as JSON or as SSE, taking the last
data: line.
If you serve with FastMCP's streamable-http transport, all of that is handled for you.
An earlier version of this gate posted a plausible-looking {"method":"list_tools"} and
every real server answered 400 — a probe written against a guessed protocol and never run
against a live MCP. That is the same mistake that let Burp sit registered and mute for the
life of the previous engine. The gate is now exercised against a real container in the
engine's mcp-live.e2e.test.ts.
The probe also retains the tool names, not just the count. "8 tools" tells an operator the server is alive; it does not tell them whether the one capability a Service needs is among them.
ProbeResult
export const zProbeResult = z.object({
healthz: { ok: boolean; latencyMs: number; detail?: string },
test: { ok: boolean; latencyMs: number; detail?: string },
listTools: { ok: boolean; latencyMs: number; detail?: string; toolCount: number },
passed: z.boolean(),
probedAt: zISO,
error: z.string().optional(),
}).refine(
(p) => !p.passed || (p.healthz.ok && p.test.ok && p.listTools.ok && p.listTools.toolCount > 0),
{ message: 'passed=true requires all three gates green AND at least one tool' },
);passed is cross-checked against the gates rather than trusted. You cannot construct a
ProbeResult claiming a pass that the gates do not support.
A gate that throws is a failed gate, never an absent one. A swallowed exception becoming "no result" becoming "fine" is a documented failure class here.
The three gates are for MCPs, and only MCPs
The other three registries are probed differently, because they are different kinds of
thing. Do not go looking for a /healthz on any of them.
| Registry | How it proves itself |
|---|---|
| McpServer | the three gates on this page |
| Model | a real invocation returning non-zero token counts. A Bedrock model is not a server. See Models. |
| Harness | an image check on a sha256: digest — scanned, recent, clean. A harness serves nothing. See Packaging. |
| KnowledgeBase | its MCP endpoint through the three gates, plus zKnowledgeBase refusing available with zero chunks — it answers HTTP and retrieves nothing. |
Giving all four the same endpoint probe is a mistake this platform has already made: a
Model and a Harness could never leave probing, and the registry pages sat empty looking
like a data problem rather than a design one.
When the probe runs
At registration, and again as a run preflight — because a probe that passed last week says nothing about right now.
Preflight refuses to start a run against a slot with nothing healthy behind it. Refusing to start is cheap; discovering it twenty minutes in, after the client's estate has been half-scanned by a tool that was never going to answer, is not.
A slot whose primary fails but whose fallback passes is allowed to start, and announced
at warn. A run that silently starts on its fallback is the same blindness as one that
silently fails over mid-run.