VERIFISDK docsengine v2 · contract 1.5.1 · harness-sdk 1.2.0
Conformance

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.

FailureWhat it meansUsual cause
healthz: status 404The route is not registered.custom_route path wrong, or the handler was added after mcp.run().
healthz: status 5xxYour 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 503Working 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 failedThe body did not contain ok: true.The probe requires res.ok && body.ok === true. Returning {"status":"ok"} is not enough.
list_tools: status 406Missing Accept: text/event-stream.Only affects a hand-rolled client; FastMCP is fine.
list_tools: unparseable responseNot JSON and not a parseable SSE data: frame.Something else is serving /mcp — a proxy error page, for instance.
list_tools: initialize: status 4xxNot speaking MCP at that path.Serve MCP at /mcp on the same base URL as the gates.
list_tools: 0 toolsThe 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.