VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
MCP authoring

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 > 0

Source: verifi-engine/src/registry/three-gate-probe.ts.

Why three and not one

THREE INDEPENDENT CHECKS · available ⟺ g1 ∧ g2 ∧ g3 ∧ toolCount > 0 GATE 1 · healthz GET /healthz res.ok — strictly 2xx pass → the process is alive The event loop is still answering.Nothing more. fail → a different problem each time A dead container — or a blockingsync tool holding the event loop, sothe probe times out while the serveris demonstrably working. async def + to_thread.run_sync GATE 2 · test GET /test res.ok && body.ok === true pass → the real backend works Its dependencies were EXECUTED, notjust found on PATH. fail → a different problem each time The server is up but its backend isnot — a truncated binary that stillresolves on PATH, a dead credential,a missing scanner. Fix the dependency, never /test GATE 3 · listTools POST /mcp → tools/list toolCount > 0 pass → it speaks MCP A real JSON-RPC session initialisedand returned tools. fail → a different problem each time The protocol is wrong (a guessedmethod name gets a 400), or theserver genuinely exposes no tools atall. Zero tools can never pass probing what register() always writes all 3 available any unavailable setStatus() cannot write available. No path to it skips the probe.
The three gates fail for three unrelated reasons, so a single “probe failed” verdict tells you almost nothing. Gate 1 green with gate 2 red is a live server with broken tools — the exact shape of the failure this design exists to catch, where a scanner reports zero findings and reads as a clean bill of health. Gate 2 green with gate 3 red is a working backend behind a protocol the engine cannot speak. The engine keeps the three results separate and joins the failure strings rather than collapsing them.

Because they are three unrelated problems, and collapsing them into "probe failed" is how a scanner line stays green while every tool reports SKIP.

  • /healthz failing is a dead container.
  • /test failing is a container that is up but whose real backend is not.
  • tools/list returning 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 reported SKIP: 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.

RegistryHow it proves itself
McpServerthe three gates on this page
Modela real invocation returning non-zero token counts. A Bedrock model is not a server. See Models.
Harnessan image check on a sha256: digest — scanned, recent, clean. A harness serves nothing. See Packaging.
KnowledgeBaseits 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.