Prove it, then register
Run the conformance runner against your MCP's URL, read the failure, fix it, and only then ask for registration.
The order is: run the probe, get a pass, then register. Not the other way round.
Registering something that has not passed does not break anything — the registry inserts it
as probing and it will simply never reach available. But you will be debugging through
someone else's console rather than your own terminal, and the failure detail you need is
right there on your machine.
What the runner is
The arbiter is the engine's threeGateProbe, in
verifi-engine/src/registry/three-gate-probe.ts. It takes a URL:
export async function threeGateProbe(opts: {
endpoint: string;
timeoutMs?: number; // default 5000
fetchImpl?: HttpLike;
now?: () => number;
logger?: Logger;
}): Promise<ProbeResultWithTools>;It speaks real HTTP and real MCP JSON-RPC and parses SSE frames. It is language-neutral by construction — it tests reality rather than agreeing with an implementation, which is precisely why it is the single arbiter rather than each SDK carrying its own copy.
Option A — hit the gates by hand
The gates are three HTTP requests. You can run them from any shell, and this is the fastest way to see which one fails.
BASE=http://127.0.0.1:8000
# ── Gate 1: liveness. Must be 2xx.
curl -sS -o /dev/null -w 'healthz: %{http_code}\n' "$BASE/healthz"
# ── Gate 2: readiness. Must be 2xx AND the body must have ok:true.
curl -sS -w '\ntest: %{http_code}\n' "$BASE/test"
# ── Gate 3: real MCP. initialize → initialized → tools/list.
# Note BOTH accept types — a streamable-http server answers 406 without them.
ACCEPT='Accept: application/json, text/event-stream'
CT='Content-Type: application/json'
# Capture the session id the server issues.
SID=$(curl -sS -D - -o /dev/null -X POST "$BASE/mcp" -H "$CT" -H "$ACCEPT" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-probe","version":"1.0.0"}}}' \
| tr -d '\r' | awk -F': ' '/^mcp-session-id:/ {print $2}')
curl -sS -o /dev/null -X POST "$BASE/mcp" -H "$CT" -H "$ACCEPT" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
curl -sS -X POST "$BASE/mcp" -H "$CT" -H "$ACCEPT" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'The last response must list at least one tool. Count them yourself; that is the gate.
Option B — probe through the engine
If your MCP is already registered, the engine exposes the probe as a route. It is a POST because it has an effect: an operator hitting "probe" is making a claim about the world.
POST /registries/mcps/:id/probe → { entry } the ONLY path to `available`
GET /registries/mcps → { entries }
POST /registries/mcps/:id/status → operator status change; CANNOT set `available`The response entry carries status, lastProbedAt, lastProbeError, and — when the gate
got far enough to ask — tools and toolsProbedAt.
Reading a failure
The probe's error string joins the failed gates. Each one means something different, and
that distinction is the entire diagnostic.
| Failure | What it means | Usual cause |
|---|---|---|
healthz: status 404 | The route is not registered. | custom_route path wrong, or the handler was added after mcp.run(). |
healthz: status 5xx | Your liveness endpoint is touching a backend. | Move the backend check to /test. |
healthz: <network error> | Nothing is listening, or it is bound to 127.0.0.1 inside a container. | Bind 0.0.0.0. |
test: status 503 | Working as designed — your own readiness check said no. | Read your detail field; a required binary is missing or not executable. |
test: status 200 but the gate failed | The body did not contain ok: true. | The probe requires res.ok && body.ok === true. Returning {"status":"ok"} is not enough. |
list_tools: status 406 | Missing Accept: text/event-stream. | Only affects a hand-rolled client; FastMCP is fine. |
list_tools: unparseable response | Not JSON and not a parseable SSE data: frame. | Something else is serving /mcp — a proxy error page, for instance. |
list_tools: initialize: status 4xx | Not speaking MCP at that path. | Serve MCP at /mcp on the same base URL as the gates. |
list_tools: 0 tools | The protocol works; nothing is exposed. | Tool registration failed silently at import. Check your startup logs. |
That last one is worth dwelling on. The ASM MCP auto-discovers tool modules and catches import errors:
except Exception as e:
print(f"[ERA] Failed to load tools/{_mod_info.name}.py: {e}")The server starts fine with zero tools. Gate 3 is what catches it.
Timeouts
The probe's default timeout is 5 seconds per gate. All three of your endpoints must answer inside that.
This is the trap that has bitten this platform hardest. If a tool call can block your event
loop, /healthz and /test stop answering for the duration of that call — so the probe
times out during every scan, the registry marks you unavailable while you are
demonstrably working, and new runs are refused at slot-binding time. See
Blocking work must not block the loop.
Probe your server while it is under load, not only when it is idle. A server that passes at rest and fails during a scan is worse than one that fails outright, because it fails intermittently and nobody believes the report.
Statelessness
The three gates do not test statelessness. That is a separate flag —
statelessnessProbePassed — and it gates shared, not available.
There is currently no over-the-wire runner for it. Write the interleaved two-session test yourself, and state how you tested it when you ask for registration. See Statelessness and sharing.
Registering
POST /registries/mcps
{ "id": "my-mcp", "name": "My MCP", "kind": "recon", "endpoint": "http://your-mcp.internal:8000" }Two things to know:
The body is validated against zMcpRegistration. A malformed registration is a 400
naming the field and the reason, not a stored-but-broken entry.
// 400
{
"error": "concurrencyModel: Invalid enum value. Expected 'sidecar' | 'warm_pool', received 'shared'",
"issues": [{ "path": "concurrencyModel", "message": "…" }]
}The entry is inserted as probing whatever you send. There is no request body that can
register something as already available, and setStatus throws if you pass available.
The only route is a passed probe.
Full field-by-field reference, including what each registry refuses and why: Registering a component.
Then keep proving it
The probe runs again at run preflight, because a probe that passed last week says nothing about right now. A slot with nothing healthy behind it blocks the run:
PreflightFailedError: preflight refused this run: kali (test: core tools missing/broken: nuclei).
Starting anyway would produce a scan whose thin results read as a clean bill of health.A slot whose primary fails but whose fallback passes is allowed to start degraded, and
that is announced at warn — because a run that silently starts on its fallback is the
same blindness as one that silently fails over mid-run.