#!/usr/bin/env python3
"""A daily liveness probe over every server that publishes a connect URL on the
official MCP registry, plus a public surface for the result.

EXPERIMENT E6 (`state/build-brief-2026-09-03-reset.md`). Zero model tokens: this
is pure network probing and arithmetic. No Claude, no local model, nothing to
verify against a quote — the *measurement is the source*.

WHY THIS SHAPE, AND NOT A SCORE
-------------------------------
The design argument already happened in public and we did not have to have it.
Registry issue #1445 proposed baking a static `mcp.health` field into each
entry — publisher-declared, with a 0–100 score. The author withdrew it on
2026-07-29 after two independent measurement replies, and the reasons are the
whole specification for this file (see `candidates/2026-09-07-scout.md`, which
fetched the thread):

  * health has to be **re-measured, not declared** — the failure mode *is* a
    publisher-supplied fact going stale;
  * **`dead` is the wrong thing to model.** Nothing was dead. A boolean up/down
    field marks a thousand broken endpoints healthy, because they answer HTTP
    perfectly well and simply do not speak MCP. Only a real `initialize` call
    catches that, which is why this probe does one;
  * **`lastVerified` is the load-bearing field, not `score`.** A score rots in
    weeks. So this file publishes freshness and history and refuses to compute
    a single number that ranks somebody's server;
  * the check has to name "a method that could have come out against the
    verifier" — hence: the method is written down, the raw per-day results are
    kept, and our own server is probed by the same code as everybody else's.

Three parties measured the same population independently between 2026-07 and
2026-09 (registry issue #1487, `siliroid/mcp-endpoint-census`,
`yassinht/mcp-observatory`) and converged on the same shape of failure —
protocol-level, not DNS death, in the 8–12% range. None of them built a
scoreboard, a feed or a badge. That gap is what this is for.

CLASSES
-------
Deliberately five, not two:

  healthy      `initialize` answered AND `tools/list` returned a tool array
  no_tools     `initialize` answered; `tools/list` errored or returned nothing
  gated        the endpoint demands credentials (401/403, or an OAuth challenge)
  not_mcp      answers HTTP, but the body is not an MCP `initialize` result
  unreachable  DNS failure, refused connection, TLS failure or timeout

`gated` is NOT a fault. A server may legitimately require auth before it will
say anything; we can only report that we could not complete a handshake without
credentials, and the page says so in those words.

MANNERS
-------
One request per endpoint per day, a self-identifying user agent with a contact
address, no retry storms, and a minimum gap between two hits on the same host.
Every endpoint here is a URL its own publisher listed on a public registry
specifically so that clients would connect to it. This is the same probe
`mcpbeat`, `SentinelOracle` and Anthropic's own `MCP-Stats-Prober` run against
*us* — 17,283 times in the edge log.

USAGE
-----
    python3 tools/mcphealth.py --registry              # refresh the endpoint list
    python3 tools/mcphealth.py --probe                 # today's pass (writes a day file)
    python3 tools/mcphealth.py --probe --limit 200     # a bounded rehearsal
    python3 tools/mcphealth.py --render                # site/mcp-health/ + shards + feed
    python3 tools/mcphealth.py --report                # what the last pass found
    python3 tools/mcphealth.py --drift                 # whose tool contract moved

Written 3.8-safe (no walrus, no f-strings in the hot paths, typing kept simple)
so it can move to old-mac, which is always on and is the right long-term home
for a *daily* job. See `ops/mcp-health.md`.
"""
from __future__ import annotations

import argparse
import datetime as dt
import gzip
import hashlib
import json
import pathlib
import re
import shutil
import socket
import ssl
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor

ROOT = pathlib.Path(__file__).resolve().parents[1]
STATE = ROOT / "state" / "mcp-health"
DAYS = STATE / "days"
REGISTRY_FILE = STATE / "registry.json"
LATEST_FILE = STATE / "latest.json"
RUNS_FILE = STATE / "runs.jsonl"
# The rolling tool-contract snapshot (one file, overwritten) and the permanent
# log of every change it has seen (append-only). See schema_fingerprint below
# for why the snapshot holds hashes and not a word of anybody's schema.
SCHEMA_FILE = STATE / "schemas.json.gz"
DRIFT_FILE = STATE / "drift.jsonl"

REGISTRY_API = "https://registry.modelcontextprotocol.io/v0/servers"
CONTACT = "contact@referencesource.org"
UA = ("referencesource-mcp-health/0.1 (+https://referencesource.org/mcp-health/; "
      "daily MCP liveness probe; opt out: " + CONTACT + ")")

# The one place this dataset's licence is worded. /method/ promises that "each
# dataset carries its own licence, stated on its page", and until 2026-09-10
# this was the only registered dataset that did not: the statement lived in
# feed.json alone, so a reuser reading the page -- or a directory whose
# submission schema has a mandatory licence field -- could not get an answer.
# HTML, index.md, feed.json and the JSON-LD all read it from here, for the same
# reason the badge SVG has only one implementation: a licence that disagreed
# with itself across two surfaces would be worse than no licence at all.
#
# Two halves, because this dataset holds two kinds of content. The verdicts and
# timings are our own measurements and nobody else has a claim on them, so they
# go out CC0. The endpoint list is not ours: each server's registry name and
# connect URL are facts its publisher posted on a public registry so that
# clients would connect to them. We take those two fields and nothing else --
# no description, no tool schema, no code -- which is why this needs no grant
# from the registry (whose repository is Apache-2.0 for code and CC-BY-4.0 for
# documentation; we reproduce neither).
LICENCE = (
    "CC0 1.0 / public domain, for the measurements. The verdict, latency, "
    "tool count, protocol version, 7- and 30-day uptime and last-healthy date "
    "for each endpoint are our own observations, and we place them in the "
    "public domain: copy, re-publish or sell them, no permission needed, and "
    "attribution is welcome rather than required. The endpoint list is not "
    "ours to license and needs no licence: each server's registry name and "
    "connect URL are facts its publisher posted on the official MCP registry "
    "so that clients would connect to them, and those two fields are all we "
    "take from it — no description, no tool schema, no code. Every server "
    "named here is named because its own publisher published the address. To "
    "have a server removed from the probe, write to " + CONTACT + "."
)

# The protocol version we ASK for. A server that speaks a different one is
# supposed to answer with its own, so this is a negotiation opener and not a
# filter -- picking the newest draft would misclassify conservative servers as
# broken, which is exactly the mistake this whole file exists to avoid.
PROTOCOL_VERSION = "2025-06-18"

CONNECT_TIMEOUT = 10.0
MIN_HOST_GAP = 1.0            # seconds between two requests to one host
MAX_BODY = 256 * 1024         # a liveness probe never needs more than this
HISTORY_DAYS = 30

CLASSES = ["healthy", "no_tools", "gated", "not_mcp", "unreachable"]
CLASS_LABEL = {
    "healthy": "answered, tools listed",
    "no_tools": "answered, no tool list",
    "gated": "needs credentials",
    "not_mcp": "answers HTTP, not MCP",
    "unreachable": "no usable response (DNS, TLS, timeout or 5xx)",
}
def _utcnow():
    """3.8-safe, and not the deprecated naive utcnow()."""
    return dt.datetime.now(dt.timezone.utc).replace(
        microsecond=0, tzinfo=None).isoformat() + "Z"


CLASS_CHAR = {"healthy": "H", "no_tools": "T", "gated": "G",
              "not_mcp": "X", "unreachable": "U"}


# --------------------------------------------------------------------------
# the registry


def _get_json(url, timeout=30.0, tries=3):
    err = None
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": UA})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode("utf-8", "replace"))
        except Exception as e:                       # noqa: BLE001 - reported
            err = e
            time.sleep(1.5 * (i + 1))
    raise err


def fetch_registry(verbose=True):
    """Page the whole registry and keep every latest, active entry with a URL.

    `version=latest` matters: without it the registry returns every published
    version of every server, and `ac.inference.sh/mcp` alone appears four times.
    Probing those would report one endpoint four times and inflate every count.
    """
    out, cursor, pages = [], None, 0
    placeholders = []
    started = time.time()
    seen_versions = 0
    while True:
        url = REGISTRY_API + "?limit=100&version=latest"
        if cursor:
            url += "&cursor=" + urllib.parse.quote(cursor)
        d = _get_json(url)
        pages += 1
        for entry in d.get("servers") or []:
            s = entry.get("server") or {}
            meta = (entry.get("_meta") or {}).get(
                "io.modelcontextprotocol.registry/official") or {}
            seen_versions += 1
            if meta.get("status") and meta.get("status") != "active":
                continue
            for rem in s.get("remotes") or []:
                u = (rem.get("url") or "").strip()
                if not u.lower().startswith(("http://", "https://")):
                    continue
                # `https://{installation_domain}/mcp` is a real registry entry.
                # An unsubstituted template is not an endpoint that is down, it
                # is an entry that never named one, and probing it would file a
                # DNS failure against a server nobody ever deployed. Counted,
                # not probed.
                if "{" in u or "}" in u or " " in u:
                    placeholders.append({"name": s.get("name") or "", "url": u})
                    continue
                out.append({
                    "name": s.get("name") or "",
                    "title": s.get("title") or "",
                    "description": (s.get("description") or "")[:400],
                    "version": s.get("version") or "",
                    "url": u,
                    "transport": rem.get("type") or "streamable-http",
                    "published": meta.get("publishedAt") or "",
                    "updated": meta.get("updatedAt") or "",
                })
        cursor = (d.get("metadata") or {}).get("nextCursor")
        if verbose and pages % 25 == 0:
            print("  registry: {0} pages, {1} endpoints".format(pages, len(out)),
                  flush=True)
        if not cursor:
            break
    payload = {
        "fetched": _utcnow(),
        "source": REGISTRY_API + "?version=latest",
        "servers_seen": seen_versions,
        "endpoints": len(out),
        "placeholder_urls": placeholders,
        "list": out,
    }
    STATE.mkdir(parents=True, exist_ok=True)
    REGISTRY_FILE.write_text(json.dumps(payload, indent=1), encoding="utf-8")
    if verbose:
        print("registry: {0:,} active servers, {1:,} remote endpoints "
              "({2:.0f}s) -> {3}".format(
                  seen_versions, len(out), time.time() - started,
                  REGISTRY_FILE.relative_to(ROOT)))
        if placeholders:
            print("  {0} entry(ies) publish an unsubstituted template as their "
                  "URL (e.g. {1}) — counted, not probed".format(
                      len(placeholders), placeholders[0]["url"][:60]))
    return payload


def load_registry():
    if not REGISTRY_FILE.exists():
        return None
    return json.loads(REGISTRY_FILE.read_text(encoding="utf-8"))


# --------------------------------------------------------------------------
# the probe

_host_last = {}
_host_gap = {}
_host_429 = {}
_host_lock = threading.Lock()


def set_host_gaps(endpoints):
    """Per-host minimum spacing, set from how many endpoints share the host.

    The registry is not evenly spread: `gateway.pipeworx.io` alone carries 1,318
    of 16,786 endpoints and `api.mcp.ai` another 1,115. At a flat one-second gap
    those two hosts would take over an hour of mostly-idle waiting and would be
    the whole run time. They are multi-tenant gateways — one HTTP endpoint
    fronting a thousand different people's servers — so a gap chosen for a small
    self-hosted box is the wrong instrument. Big hosts get 0.25s (≈4 req/s to a
    production gateway, for one probe per server per day); everything else keeps
    the full second.
    """
    counts = {}
    for e in endpoints:
        h = urllib.parse.urlsplit(e["url"]).hostname or ""
        counts[h] = counts.get(h, 0) + 1
    with _host_lock:
        for h, n in counts.items():
            _host_gap[h] = 0.25 if n > 20 else MIN_HOST_GAP


def _note_429(host):
    """A host that rate-limits us loses its big-host discount for the day.

    The 0.25s above is a guess about what a production gateway is happy with,
    and a 429 is that gateway telling us the guess was wrong. So the host drops
    back to the ordinary MIN_HOST_GAP that every small self-hosted box already
    gets — one step, not an escalating back-off, and it cannot go past the
    politeness level the rest of the run already uses.

    It is deliberately not the other lesson from 2026-09-08, when 379 *distinct*
    hosts each returned one 429 and pacing had nothing to do with it. This only
    moves a host we are demonstrably sending a lot of traffic to: with 1,489
    endpoints on `gateway.pipeworx.io` at three requests each, 0.25s is ~12
    requests a second and 1,207 of those endpoints were refused at `tools/list`
    every day from 09-13. Fewer endpoints get reached at 1.0s; the difference
    is that the ones reached come back with an answer instead of a refusal.
    """
    with _host_lock:
        _host_429[host] = _host_429.get(host, 0) + 1
        if _host_gap.get(host, MIN_HOST_GAP) < MIN_HOST_GAP:
            _host_gap[host] = MIN_HOST_GAP


def _wait_for_host(host):
    """Never two requests to one host inside that host's minimum gap."""
    while True:
        with _host_lock:
            gap = _host_gap.get(host, MIN_HOST_GAP)
            now = time.time()
            last = _host_last.get(host, 0.0)
            if now - last >= gap:
                _host_last[host] = now
                return
            wait = gap - (now - last)
        time.sleep(min(wait, gap))


def interleave_by_host(endpoints):
    """Round-robin across hosts, so the in-flight set is mostly distinct hosts.

    Without this the list arrives sorted by server name, which for the gateways
    above means a thousand consecutive tasks all queueing behind one host's gap
    while every other worker sits idle.
    """
    groups = {}
    for e in endpoints:
        groups.setdefault(
            urllib.parse.urlsplit(e["url"]).hostname or "", []).append(e)
    order = sorted(groups, key=lambda h: -len(groups[h]))
    out = []
    i = 0
    while len(out) < len(endpoints):
        added = False
        for h in order:
            g = groups[h]
            if i < len(g):
                out.append(g[i])
                added = True
        if not added:
            break
        i += 1
    return out


def _read_message(resp):
    """One JSON-RPC message out of either a JSON body or an SSE stream.

    Streamable-HTTP servers may answer a POST with `application/json` or with
    `text/event-stream` carrying the same object in a `data:` line. Treating the
    second as a failure would misclassify a large and entirely healthy slice of
    the registry, so both are read here and nowhere else.
    """
    ctype = (resp.headers.get("Content-Type") or "").lower()
    if "text/event-stream" in ctype:
        buf, read = [], 0
        while read < MAX_BODY:
            line = resp.readline()
            if not line:
                break
            read += len(line)
            line = line.decode("utf-8", "replace").rstrip("\r\n")
            if line.startswith("data:"):
                buf.append(line[5:].lstrip())
            elif not line and buf:
                break
        raw = "\n".join(buf)
    else:
        raw = resp.read(MAX_BODY).decode("utf-8", "replace")
    raw = raw.strip()
    if not raw:
        return None, ""
    try:
        return json.loads(raw), raw[:400]
    except ValueError:
        return None, raw[:400]


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Surface every 3xx to the caller instead of letting urllib chase it.

    The default handling is actively wrong for an RPC probe, in two different
    ways, and both of them end up blaming somebody else's server for our
    request:

      * on 301/302/303 urllib follows the hop but **rewrites our POST into a
        GET**. An MCP `initialize` that arrives as a bodyless GET gets whatever
        the endpoint serves to browsers, which is not a JSON-RPC envelope — so
        the endpoint is filed as "answers HTTP, not MCP" on the strength of a
        request we never actually made. Measured 2026-09-08: 340 endpoints were
        recorded with "no JSON-RPC envelope in the response body", and this is
        one of the ways to arrive there.
      * on 307/308, which exist precisely to preserve the method, urllib
        declines to follow at all and raises — so a server that has merely
        *moved* was filed as not speaking MCP. That was 140 endpoints.

    So `_post` follows redirects itself, re-POSTing the same body to the new
    location. A registry entry is a publisher-supplied string; one that now
    redirects to the working URL is an ordinary thing, not a fault.
    """

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


_OPENER = urllib.request.build_opener(_NoRedirect)

MAX_HOPS = 3


def _post(url, body, extra=None, timeout=CONNECT_TIMEOUT, plain=False,
          _hops=0):
    data = json.dumps(body).encode("utf-8")
    headers = {
        "User-Agent": UA,
        "Content-Type": "application/json",
        # Both, in this order: the spec requires a client to advertise it can
        # take either, and servers that check reject a JSON-only Accept.
        "Accept": "application/json, text/event-stream",
        "MCP-Protocol-Version": PROTOCOL_VERSION,
    }
    if plain:
        del headers["MCP-Protocol-Version"]
    if extra:
        headers.update(extra)
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
    try:
        return _OPENER.open(req, timeout=timeout)
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location") if e.headers else None
        if e.code in (301, 302, 303, 307, 308) and loc and _hops < MAX_HOPS:
            nxt = urllib.parse.urljoin(url, loc)
            if urllib.parse.urlsplit(nxt).scheme in ("http", "https"):
                _wait_for_host(urllib.parse.urlsplit(nxt).hostname or "")
                return _post(nxt, body, extra=extra, timeout=timeout,
                             plain=plain, _hops=_hops + 1)
        raise


def _auth_challenge(headers):
    v = (headers.get("WWW-Authenticate") or "") if headers else ""
    return bool(v)


def _class_for_status(code, headers=None):
    """What an HTTP status alone tells us, before any body is read.

    Two calls here are deliberate and both were wrong in the first draft:
    **402 and 407 are gating, not brokenness** — a paywall or a proxy asking
    for credentials is the same finding as a 401, namely that we cannot get in
    without an account; and **a 5xx is not "answers HTTP but is not MCP"** — the
    host is telling us it cannot serve, which belongs with the endpoints that
    gave us no usable response rather than with the ones running the wrong
    protocol. Calling a 503 "not MCP" would have quietly slandered a server
    that was merely restarting.
    """
    if code in (401, 402, 403, 407) or _auth_challenge(headers):
        return "gated"
    if code >= 500:
        return "unreachable"
    return "not_mcp"


# --------------------------------------------------------------------------
# the tool contract, and whether it moved
#
# Liveness is the easy half. What a client actually consumes is the *contract*:
# the tool names, their descriptions and their input schemas. A server that goes
# down is loud — the caller gets an error and somebody notices within the hour.
# A server that renames a tool, tightens a required field or rewrites a
# description is silent: the session still completes, the scoreboard still says
# `healthy`, and the failure surfaces to a user as "the assistant is being weird
# today". Two independent parties who probe this same population both ship drift
# detection as their differentiator over plain uptime — mymcptools.com, which
# this page already names, and `io.github.operatorsheets/mcp-uptime`, listed on
# the official registry since 2026-08-01 with a `check_schema_drift` tool
# (`candidates/2026-09-29-scout.md` fetched both). We had the tool array in hand
# on every healthy probe and were throwing it away.
#
# WHAT IS STORED, AND WHY IT IS HASHES AND NOT TEXT
# -------------------------------------------------
# Nothing readable. Per tool we keep two short hex digests — one of the name,
# one of the name plus its description plus its input schema — and nothing else.
# That is not a size optimisation, or not only: the licence statement at the top
# of this file tells every reader that the registry name and the connect URL are
# "all we take from it — no description, no tool schema, no code", and that
# sentence has to stay literally true. A digest is a one-way function of
# somebody else's text, not a copy of it, so the drift count can be published
# without publishing a word of anyone's schema. It also means the store cannot
# become a back door into republishing the registry's documentation.
#
# The per-tool map is keyed by a hash of the tool NAME rather than hashing the
# concatenated list, because `tools/list` makes no ordering promise. A server
# that shuffles its array between two probes would otherwise read as a total
# rewrite every single day, which is exactly the false alarm that makes a drift
# feed worthless.


def schema_fingerprint(tools):
    """(bad_count, inventory_sig, {name_hash: tool_hash}) for one tool array.

    `bad_count` is the same cheap validity check this file has always made — a
    tool a client can call needs a name and an object input schema — kept here
    so the two transports cannot drift apart in what they count.
    """
    bad = 0
    sigs = {}
    for t in tools:
        if not isinstance(t, dict) or not t.get("name"):
            bad += 1
            continue
        sch = t.get("inputSchema")
        if not isinstance(sch, dict) or sch.get("type") != "object":
            bad += 1
        name = str(t.get("name"))
        try:
            body = json.dumps(
                {"n": name, "d": t.get("description"), "s": sch},
                sort_keys=True, separators=(",", ":"), default=str)
        except Exception:                                 # noqa: BLE001
            # An unserialisable schema is still a contract, and refusing to
            # fingerprint it would silently exempt that server from drift.
            body = name + "|" + repr(sch)[:4000]
        sigs[hashlib.sha1(name.encode("utf-8", "replace")).hexdigest()[:8]] = \
            hashlib.sha1(body.encode("utf-8", "replace")).hexdigest()[:8]
    inv = hashlib.sha1(
        "".join(k + ":" + v for k, v in sorted(sigs.items()))
        .encode("ascii")).hexdigest()[:12] if sigs else None
    return bad, inv, sigs


def diff_fingerprints(prev, now):
    """How many tools were added, removed, or changed in place."""
    pk = set(prev or {})
    nk = set(now or {})
    changed = len([k for k in (pk & nk) if prev[k] != now[k]])
    return {"added": len(nk - pk), "removed": len(pk - nk),
            "changed": changed}


def load_schema_snapshot():
    """The last tool contract we saw for each endpoint, whenever we saw it.

    Keyed the same way as everything else here (`name|url`), and each entry
    carries its own observation date — because an endpoint that is gated or
    unreachable today must be compared against the last day it actually
    answered, not treated as having lost every tool it had.
    """
    if not SCHEMA_FILE.exists():
        return {}
    try:
        with gzip.open(str(SCHEMA_FILE), "rt", encoding="utf-8") as fh:
            return (json.load(fh) or {}).get("servers") or {}
    except Exception:                                     # noqa: BLE001
        return {}


def save_schema_snapshot(servers, day):
    STATE.mkdir(parents=True, exist_ok=True)
    tmp = SCHEMA_FILE.with_suffix(".tmp")
    with gzip.open(str(tmp), "wt", encoding="utf-8") as fh:
        json.dump({"generated": _utcnow(), "date": day, "servers": servers}, fh)
    tmp.replace(SCHEMA_FILE)


def apply_drift(results, day, persist=True):
    """Score today's tool contracts against the last ones we saw.

    Mutates `results` in place: every row that listed tools gains a
    `schema_sig`, and a row whose contract moved gains a `drift` dict naming
    what moved and the date it moved from. The per-tool map never reaches the
    day file — it lives only in the rolling snapshot, so a day file grows by a
    few bytes per endpoint rather than by a line per tool.

    An endpoint seen for the FIRST time gets no `drift`. A new server is not a
    server that added forty tools, and publishing it as one would make the
    first week of this feed pure noise.
    """
    snap = load_schema_snapshot()
    events = []
    for r in results:
        sigs = r.pop("tool_sigs", None)
        if sigs is None:
            continue
        k = key_of(r)
        prior = snap.get(k)
        if prior and prior.get("sig") and prior.get("sig") != r.get("schema_sig"):
            d = diff_fingerprints(prior.get("t") or {}, sigs)
            d["since"] = prior.get("d")
            r["drift"] = d
            events.append({"date": day, "name": r["name"], "url": r["url"],
                           "since": prior.get("d"), "added": d["added"],
                           "removed": d["removed"], "changed": d["changed"],
                           "tools": r.get("tools")})
        snap[k] = {"d": day, "sig": r.get("schema_sig"), "t": sigs}
    if persist:
        save_schema_snapshot(snap, day)
        if events:
            with DRIFT_FILE.open("a", encoding="utf-8") as fh:
                for e in events:
                    fh.write(json.dumps(e) + "\n")
    return events


# --------------------------------------------------------------------------
# the legacy HTTP+SSE transport
#
# MCP has had two remote transports. Streamable HTTP (2025-03-26 onward) is one
# POST per message, which is what `_post` above does. The ORIGINAL transport
# (2024-11-05) is two channels: the client GETs the published URL with
# `Accept: text/event-stream`, the server answers with an open stream whose
# first frame is `event: endpoint` naming a *separate* URL, and every client
# message is POSTed there while every reply comes back down the open GET.
#
# A POST to a legacy SSE URL cannot complete a handshake however healthy the
# server is, and the first version of this file POSTed to everything. The cost
# was measurable and it was ours: 52.1% of the endpoints that told the registry
# they speak `sse` were published as "answers HTTP, not MCP", against 5.4% of
# the streamable-HTTP ones. A correct re-probe of 45 of those 505 found 33.3%
# were working servers and 17.8% merely wanted credentials — 51.1% in the wrong
# bucket, including one production server at v4.97.0 exposing 302 tools.
#
# Note the direction this can move a verdict. The SSE attempt can only take an
# endpoint OUT of `not_mcp`; it never puts one in. If the legacy handshake does
# not settle the question, the streamable-HTTP path runs exactly as before and
# owns the verdict.

SSE_FIRST_FRAME = 8.0     # seconds to wait for `event: endpoint`
SSE_REPLY_WAIT = 12.0     # seconds to wait for a reply to one message


def _sse_frames(resp, deadline):
    """Yield `(event, data)` off an open SSE stream until `deadline[0]` passes.

    `deadline` is a one-element list so the caller can push it forward between
    messages without rebuilding the generator — the stream has to stay open
    across the whole session, and a fresh generator would lose buffered frames.
    """
    event, data, read = "", [], 0
    while read < MAX_BODY and time.time() < deadline[0]:
        line = resp.readline()
        if not line:
            return
        read += len(line)
        line = line.decode("utf-8", "replace").rstrip("\r\n")
        if not line:
            if data:
                yield (event or "message"), "\n".join(data)
            event, data = "", []
            continue
        if line.startswith(":"):
            continue                        # a comment, i.e. a keep-alive ping
        if line.startswith("event:"):
            event = line[6:].strip()
        elif line.startswith("data:"):
            data.append(line[5:].lstrip())
    if data:
        yield (event or "message"), "\n".join(data)


def _get_stream(url, host, _hops=0):
    """GET an SSE stream, following redirects ourselves (see `_NoRedirect`)."""
    _wait_for_host(host)
    req = urllib.request.Request(url, headers={
        "User-Agent": UA,
        "Accept": "text/event-stream",
        "Cache-Control": "no-cache",
        "MCP-Protocol-Version": "2024-11-05",
    })
    try:
        return _OPENER.open(req, timeout=CONNECT_TIMEOUT), url
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location") if e.headers else None
        if e.code in (301, 302, 303, 307, 308) and loc and _hops < MAX_HOPS:
            nxt = urllib.parse.urljoin(url, loc)
            if urllib.parse.urlsplit(nxt).scheme in ("http", "https"):
                return _get_stream(nxt, urllib.parse.urlsplit(nxt).hostname or "",
                                   _hops=_hops + 1)
        raise


class _SSESession(object):
    """One legacy HTTP+SSE session: the open stream plus its POST endpoint."""

    def __init__(self, url, host):
        self.url = url
        self.host = host
        self.resp = None
        self.post_url = None
        self._deadline = [0.0]
        self._frames = None

    def open(self):
        """True once `event: endpoint` has named a POST URL.

        False means the stream opened but this is not the legacy transport —
        the content type was something else, or the server started talking
        JSON-RPC without naming an endpoint. Raises on HTTP or transport
        failure so the caller can read the status.
        """
        self.resp, self.url = _get_stream(self.url, self.host)
        ctype = (self.resp.headers.get("Content-Type") or "").lower()
        if "text/event-stream" not in ctype:
            return False
        self._deadline[0] = time.time() + SSE_FIRST_FRAME
        self._frames = _sse_frames(self.resp, self._deadline)
        for event, data in self._frames:
            if event == "endpoint" and data.strip():
                self.post_url = urllib.parse.urljoin(self.url, data.strip())
                return True
            if event == "message":
                return False
        return False

    def send(self, body):
        """POST one message. Returns an inline reply if the server gave one."""
        _wait_for_host(urllib.parse.urlsplit(self.post_url).hostname or self.host)
        data = json.dumps(body).encode("utf-8")
        req = urllib.request.Request(self.post_url, data=data, headers={
            "User-Agent": UA,
            "Content-Type": "application/json",
            "Accept": "application/json, text/event-stream",
        }, method="POST")
        r = _OPENER.open(req, timeout=CONNECT_TIMEOUT)
        # A conforming server answers 202 with an empty body and puts the reply
        # on the stream. A few answer inline; both are read.
        try:
            raw = r.read(MAX_BODY).decode("utf-8", "replace").strip()
        finally:
            try:
                r.close()
            except Exception:                         # noqa: BLE001
                pass
        if raw:
            try:
                msg = json.loads(raw)
            except ValueError:
                return None
            return msg if isinstance(msg, dict) else None
        return None

    def call(self, body):
        """Send a request and read its reply off the stream, by id."""
        inline = self.send(body)
        if isinstance(inline, dict) and inline.get("id") == body.get("id"):
            return inline
        self._deadline[0] = time.time() + SSE_REPLY_WAIT
        for _event, data in self._frames:
            try:
                msg = json.loads(data)
            except ValueError:
                continue
            if isinstance(msg, dict) and msg.get("id") == body.get("id"):
                return msg
        return None

    def close(self):
        try:
            self.resp.close()
        except Exception:                             # noqa: BLE001
            pass


def _probe_over_sse(url, host):
    """The legacy handshake. A verdict dict, or None meaning "did not settle".

    None is the important half: it hands the endpoint back to the streamable
    HTTP path unchanged. Only three things settle here — a completed session,
    an empty tool list, and a server that asks for credentials — and all three
    are findings this transport can establish and a POST cannot.
    """
    sess = _SSESession(url, host)
    t0 = time.time()
    try:
        opened = sess.open()
    except urllib.error.HTTPError as e:
        cls = _class_for_status(e.code, e.headers)
        if cls == "gated":
            return {"class": "gated", "ms": int((time.time() - t0) * 1000),
                    "http": e.code, "tools": None, "protocol": None,
                    "server_name": None, "server_version": None, "via": "sse",
                    "detail": "HTTP {0} on the SSE stream: needs "
                              "credentials".format(e.code)}
        return None
    except Exception:                                 # noqa: BLE001
        return None
    try:
        if not opened:
            return None
        init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
                "params": {"protocolVersion": "2024-11-05",
                           "capabilities": {},
                           "clientInfo": {"name": "referencesource-mcp-health",
                                          "version": "0.1"}}}
        try:
            msg = sess.call(init)
        except Exception:                             # noqa: BLE001
            return None
        result = (msg or {}).get("result")
        if not isinstance(result, dict) or "protocolVersion" not in result:
            return None
        ms = int((time.time() - t0) * 1000)
        info = result.get("serverInfo") or {}
        res = {"class": "no_tools", "ms": ms, "http": 200, "tools": None,
               "protocol": str(result.get("protocolVersion"))[:32],
               "server_name": str(info.get("name") or "")[:80],
               "server_version": str(info.get("version") or "")[:32],
               "via": "sse",
               "detail": "legacy HTTP+SSE transport"}
        try:
            sess.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
        except Exception:                             # noqa: BLE001
            pass                    # a notification failing is not a verdict
        try:
            msg3 = sess.call({"jsonrpc": "2.0", "id": 2, "method": "tools/list",
                              "params": {}})
        except urllib.error.HTTPError as e:
            # Same guard as the POST path: rate-limited is not a verdict. A
            # class of None means "no result" all the way up -- run_probe drops
            # the row, and probe_endpoint returning it in place of a `not_mcp`
            # is right too, because the SSE initialize already disproved that.
            if e.code == 429:
                _note_429(host)
                res["class"] = None
                res["detail"] = ("HTTP 429 at tools/list on the SSE stream: "
                                 "rate-limited before we could ask")
                return res
            res["detail"] = ("legacy HTTP+SSE transport; initialize ok; "
                             "tools/list " + _short_error(e))
            return res
        except Exception as e:                        # noqa: BLE001
            res["detail"] = ("legacy HTTP+SSE transport; initialize ok; "
                             "tools/list " + _short_error(e))
            return res
        tools = None
        if isinstance(msg3, dict) and isinstance(msg3.get("result"), dict):
            tools = msg3["result"].get("tools")
        if isinstance(tools, list):
            res["tools"] = len(tools)
            bad, sig, sigs = schema_fingerprint(tools)
            res["bad_schema"] = bad
            res["schema_sig"] = sig
            res["tool_sigs"] = sigs
            res["class"] = "healthy" if tools else "no_tools"
            if not tools:
                res["detail"] = ("legacy HTTP+SSE transport; initialize ok; "
                                 "tools/list returned an empty array")
        else:
            err = (msg3 or {}).get("error") or {}
            res["detail"] = ("legacy HTTP+SSE transport; initialize ok; "
                             "tools/list error: "
                             + str(err.get("message")
                                   or "no reply on the stream")[:160])
        return res
    finally:
        sess.close()


def probe_endpoint(ep):
    """One endpoint, one verdict. Never raises.

    Two transports, tried in the order the publisher's own declaration
    suggests. The declaration is a hint and not a filter: a server that
    declared `sse` and actually speaks streamable HTTP still falls through to
    the POST path, and a server that declared streamable HTTP but is really on
    the legacy transport gets the SSE attempt as a second chance — but only
    after the POST path has concluded `not_mcp`, which is the one verdict that
    makes a public negative claim about somebody else's server.
    """
    declared = (ep.get("transport") or "").lower()
    url = ep["url"]
    host = urllib.parse.urlsplit(url).hostname or ""
    if declared == "sse":
        res = _probe_over_sse(url, host)
        if res is not None:
            return res
    res = _probe_over_post(ep)
    if res.get("class") == "not_mcp" and declared != "sse":
        alt = _probe_over_sse(url, host)
        if alt is not None and alt.get("class") != "not_mcp":
            return alt
    return res


def _probe_over_post(ep):
    """The streamable-HTTP path: one POST per message. Never raises."""
    url = ep["url"]
    host = urllib.parse.urlsplit(url).hostname or ""
    res = {"class": "unreachable", "ms": None, "http": None, "tools": None,
           "protocol": None, "server_name": None, "server_version": None,
           "via": "post", "detail": ""}
    _wait_for_host(host)
    t0 = time.time()
    init = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
            "params": {"protocolVersion": PROTOCOL_VERSION,
                       "capabilities": {},
                       "clientInfo": {"name": "referencesource-mcp-health",
                                      "version": "0.1"}}}
    session = None
    try:
        resp = _post(url, init)
        res["http"] = resp.status if hasattr(resp, "status") else resp.getcode()
        session = resp.headers.get("Mcp-Session-Id")
        msg, raw = _read_message(resp)
        res["ms"] = int((time.time() - t0) * 1000)
    except urllib.error.HTTPError as e:
        res["ms"] = int((time.time() - t0) * 1000)
        res["http"] = e.code
        # 429 IS NOT A VERDICT. Being rate-limited says nothing whatever about
        # whether the endpoint speaks MCP -- we were refused the chance to ask.
        # Filing it under "answers HTTP, not MCP" published a negative claim
        # about 379 named third-party servers on 2026-09-08 that the
        # measurement did not support. `None` means "no result": run_probe
        # drops the row, it is absent from the day file, and the history strip
        # draws it as a gap, which is the same honest treatment an endpoint
        # gets when the time-box stops us reaching it at all.
        #
        # Not our pacing, before anyone reaches for MIN_HOST_GAP: those 379
        # were 379 DISTINCT hosts, one request each.
        if e.code == 429:
            _note_429(host)
            res["class"] = None
            res["detail"] = "HTTP 429: rate-limited before we could ask"
            return res
        res["class"] = _class_for_status(e.code, e.headers)
        res["detail"] = "HTTP {0}".format(e.code)
        # 400 / 406 / 415 can be the *headers* rather than the server: some
        # implementations reject an MCP-Protocol-Version they do not know, or
        # the dual Accept. Filing those as "not MCP" would be our bug reported
        # as somebody else's, so the one case that could be our fault gets one
        # retry with the plainest request that is still a valid MCP call.
        if e.code in (400, 406, 415):
            try:
                _wait_for_host(host)
                resp = _post(url, init, plain=True)
                res["http"] = resp.status if hasattr(resp, "status") \
                    else resp.getcode()
                session = resp.headers.get("Mcp-Session-Id")
                msg, raw = _read_message(resp)
                res["detail"] = "HTTP {0} with the protocol header; retried " \
                                "without it".format(e.code)
            except Exception:                         # noqa: BLE001
                return res
        else:
            return res
    except (urllib.error.URLError, socket.timeout, ssl.SSLError,
            ConnectionError, TimeoutError, OSError) as e:
        res["ms"] = int((time.time() - t0) * 1000)
        res["class"] = "unreachable"
        res["detail"] = _short_error(e)
        return res
    except Exception as e:                            # noqa: BLE001
        res["ms"] = int((time.time() - t0) * 1000)
        res["class"] = "unreachable"
        res["detail"] = _short_error(e)
        return res

    if not isinstance(msg, dict) or "jsonrpc" not in msg:
        res["class"] = "not_mcp"
        res["detail"] = "no JSON-RPC envelope in the response body"
        return res
    if "error" in msg and "result" not in msg:
        err = msg.get("error") or {}
        text = str(err.get("message") or "")[:160]
        # An initialize that is REFUSED for credentials is gated, not broken.
        if re.search(r"unauthor|forbidden|api[- ]?key|token|auth", text, re.I):
            res["class"] = "gated"
        else:
            res["class"] = "not_mcp"
        res["detail"] = "initialize error: " + text
        return res
    result = msg.get("result")
    if not isinstance(result, dict) or "protocolVersion" not in result:
        res["class"] = "not_mcp"
        res["detail"] = "initialize result carried no protocolVersion"
        return res
    res["protocol"] = str(result.get("protocolVersion"))[:32]
    info = result.get("serverInfo") or {}
    res["server_name"] = str(info.get("name") or "")[:80]
    res["server_version"] = str(info.get("version") or "")[:32]

    # Handshake completed. Now the second half: does it actually offer tools?
    hdr = {}
    if session:
        hdr["Mcp-Session-Id"] = session
    try:
        _wait_for_host(host)
        r2 = _post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
                   extra=hdr, timeout=CONNECT_TIMEOUT)
        try:
            r2.read(1024)
        except Exception:                             # noqa: BLE001
            pass
    except Exception:                                 # noqa: BLE001
        pass                     # a notification failing is not a verdict

    try:
        _wait_for_host(host)
        r3 = _post(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
                         "params": {}}, extra=hdr)
        msg3, _raw3 = _read_message(r3)
    except urllib.error.HTTPError as e:
        # The same rule as at initialize above, one step later: a 429 here is
        # not a finding about the endpoint either. "Answered the handshake but
        # offered no tool list" is a claim we cannot make about a server that
        # never got the chance to answer. Before this guard existed the POST
        # path published 1,207 rate-limited endpoints a day as `no_tools`.
        if e.code == 429:
            _note_429(host)
            res["class"] = None
            res["detail"] = "HTTP 429 at tools/list: rate-limited before " \
                            "we could ask"
            return res
        res["class"] = "no_tools"
        res["detail"] = "initialize ok; tools/list " + _short_error(e)
        return res
    except Exception as e:                            # noqa: BLE001
        res["class"] = "no_tools"
        res["detail"] = "initialize ok; tools/list " + _short_error(e)
        return res

    tools = None
    if isinstance(msg3, dict):
        tools = ((msg3.get("result") or {}).get("tools")
                 if isinstance(msg3.get("result"), dict) else None)
    if isinstance(tools, list):
        res["tools"] = len(tools)
        # Schema validity, cheaply: a tool a client can call needs a name and
        # an object input schema. This is reported, never used to fail a server.
        # The same pass fingerprints the contract so the next probe can tell
        # whether it moved -- see schema_fingerprint.
        bad, sig, sigs = schema_fingerprint(tools)
        res["bad_schema"] = bad
        res["schema_sig"] = sig
        res["tool_sigs"] = sigs
        res["class"] = "healthy" if tools else "no_tools"
        if not tools:
            res["detail"] = "initialize ok; tools/list returned an empty array"
    else:
        res["class"] = "no_tools"
        err = (msg3 or {}).get("error") or {}
        res["detail"] = ("initialize ok; tools/list error: "
                         + str(err.get("message") or "unparseable response")[:160])
    return res


def _short_error(e):
    if isinstance(e, urllib.error.URLError) and not isinstance(
            e, urllib.error.HTTPError):
        r = getattr(e, "reason", e)
        return str(r)[:120]
    return "{0}: {1}".format(type(e).__name__, e)[:120]


def run_probe(limit=None, workers=48, minutes=None, verbose=True):
    reg = load_registry()
    if not reg:
        reg = fetch_registry(verbose=verbose)
    eps = reg["list"]
    if limit:
        eps = eps[:limit]
    set_host_gaps(eps)
    eps = interleave_by_host(eps)
    day = dt.date.today().isoformat()
    started = time.time()
    # A wall-clock ceiling, because the run is scheduled and a job that can hang
    # for hours on somebody else's slow host is a job that gets killed with
    # nothing written. Endpoints not reached simply do not appear in today's
    # file, and show as "." in the history strip -- not as a failure. Reporting
    # "we did not look" as "it was down" is the one lie this file must not tell.
    deadline = started + minutes * 60 if minutes else None
    results = [None] * len(eps)
    done = [0]
    skipped = [0]
    throttled = [0]
    lock = threading.Lock()

    def work(i):
        if deadline and time.time() > deadline:
            with lock:
                skipped[0] += 1
            return
        r = probe_endpoint(eps[i])
        if r["class"] is None:            # throttled: no verdict, not a fault
            with lock:
                throttled[0] += 1
            return
        r["name"] = eps[i]["name"]
        r["url"] = eps[i]["url"]
        r["transport"] = eps[i]["transport"]
        results[i] = r
        with lock:
            done[0] += 1
            if verbose and done[0] % 500 == 0:
                print("  probed {0:,}/{1:,} ({2:.0f}s)".format(
                    done[0], len(eps), time.time() - started), flush=True)

    with ThreadPoolExecutor(max_workers=workers) as pool:
        list(pool.map(work, range(len(eps))))

    results = [r for r in results if r]
    if verbose and skipped[0]:
        print("  {0:,} endpoint(s) not reached before the {1}-minute ceiling — "
              "absent from today's file, not recorded as failures".format(
                  skipped[0], minutes))
    if verbose and throttled[0]:
        print("  {0:,} endpoint(s) rate-limited us (HTTP 429) — absent from "
              "today's file, not recorded as failures".format(throttled[0]))
        # Which hosts, because the answer changes what to do: many hosts once
        # each is the ecosystem, one host thousands of times is our pacing.
        for h, n in sorted(_host_429.items(), key=lambda kv: -kv[1])[:5]:
            print("      {0:5,}  {1}".format(n, h or "(no host)"))
    # Tool contracts, against the last ones we saw. A bounded rehearsal
    # (`--limit`) scores its rows but does NOT move the baseline: writing a
    # 200-endpoint snapshot over a 19,000-endpoint one would make the next real
    # run report drift for 200 servers and nothing for the other 18,800.
    drift = apply_drift(results, day, persist=not limit)
    if verbose and drift:
        print("  {0:,} endpoint(s) changed their tool contract since the last "
              "probe that saw one{1}".format(
                  len(drift), "" if not limit else
                  " (rehearsal: baseline not moved)"))
    counts = {c: 0 for c in CLASSES}
    for r in results:
        counts[r["class"]] = counts.get(r["class"], 0) + 1
    elapsed = round(time.time() - started, 1)

    DAYS.mkdir(parents=True, exist_ok=True)
    path = DAYS / (day + ".json.gz")
    payload = {"date": day,
               "started": _utcnow(),
               "protocol_requested": PROTOCOL_VERSION,
               "endpoints": len(results), "counts": counts,
               "drifted": len(drift),
               "seconds": elapsed, "partial": bool(limit) or bool(skipped[0]),
               "not_reached": skipped[0],
               "throttled": throttled[0],
               "throttled_hosts": dict(sorted(
                   _host_429.items(), key=lambda kv: -kv[1])[:10]),
               "results": results}
    with gzip.open(str(path), "wt", encoding="utf-8") as fh:
        json.dump(payload, fh)

    with RUNS_FILE.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps({"date": day, "at": payload["started"],
                             "endpoints": len(results), "counts": counts,
                             "drifted": len(drift),
                             "seconds": elapsed,
                             "not_reached": skipped[0],
                             "throttled": throttled[0],
                             "partial": bool(limit) or bool(skipped[0])}) + "\n")
    if verbose:
        print("probe {0}: {1:,} endpoints in {2:,.0f}s".format(
            day, len(results), elapsed))
        for c in CLASSES:
            print("  {0:<12} {1:6,}  {2:5.1f}%  {3}".format(
                c, counts[c], 100.0 * counts[c] / max(1, len(results)),
                CLASS_LABEL[c]))
    return payload


# --------------------------------------------------------------------------
# rolling state


def day_files():
    if not DAYS.is_dir():
        return []
    return sorted(DAYS.glob("*.json.gz"))


def read_day(p):
    with gzip.open(str(p), "rt", encoding="utf-8") as fh:
        return json.load(fh)


def key_of(r):
    return r["name"] + "|" + r["url"]


# --------------------------------------------------------------------------
# the exposure read: what this probe sees precisely because it never logs in
#
# Not a sixth class and not a re-grading of the five. It is the same rows
# counted along a different axis, and it is free: the day file already holds
# everything it needs.
#
# The probe sends no credentials, ever. `_post` sets four headers (User-Agent,
# Content-Type, Accept, MCP-Protocol-Version) plus `Mcp-Session-Id` once a
# session exists, and the SSE session sets the same; there is no Authorization
# header, no API key and no cookie anywhere in this file. There cannot be one:
# sending credentials is exactly how `gated` would stop being detectable. So
# every `healthy` verdict is, by construction, a server that handed its whole
# tool list to a caller it knew nothing about.
#
# WHAT THIS IS NOT. It is not a vulnerability count and it grades nobody. A
# search tool, a docs server or a public dataset is *supposed* to answer an
# anonymous caller, and `gated` stays what it has always been here: correct
# behaviour, not a fault. So the wording below says "answered without
# credentials" and never "insecure".
#
# The queue note that asked for this proposed citing TrendAI's audit as the
# credible external comparable, on the strength of an HN comment summarising it.
# The primary source was fetched (2026-09-09,
# trendaisecurity.com/en/resources-insights/research/stars-dont-save-you-...,
# dated 24 June 2026) and says something different in two ways, both of which
# cut against the framing rather than for it:
#
#   * The comment's "2,054 with no authentication at all" is a misreading.
#     2,054 is a count of *issues* of type "No authentication" inside the 4,982
#     issues found on the 2,259 retained servers. A separate 3,573 servers were
#     flagged unsecured "solely because of a lack of authentication" and were
#     EXCLUDED from the study, because TrendAI "did not consider it alarming
#     enough to flag an MCP server on its own".
#   * They crawled four directories (GitHub, Glama, Lobehub, PulseMCP) and read
#     published source; we open a live connection to a registry URL. Static
#     source can show an auth check a deployment never enforces, and a live
#     endpoint can demand credentials its repository never mentions.
#
# Both facts are stated on the page. The first is the reason this block exists
# in the shape it does: the biggest published survey of the question explicitly
# declines to treat missing auth as a finding on its own, and so do we.

# A tools/list refusal that names credentials. `no_tools` folds together two
# quite different things -- a server with nothing to list, and one that
# completed the handshake anonymously and then asked for an account at the
# second step -- and only the first belongs in an exposure count. Deliberately
# not matching a bare "auth": that hits "authoring", "authority" and any error
# message containing "author", and the precision costs nothing (65 of 303 rows
# on 2026-09-09, of which 58 are a plain HTTP 401).
CRED_REFUSAL = re.compile(
    r"unauthor|forbidden|api[- ]?key|credential|log[- ]?in|sign[- ]?in|"
    r"bearer|permission|payment required|proxy authentication|"
    r"HTTP Error (?:401|402|403|407)", re.I)


def no_auth_summary(rows):
    """How much of the probed population answers a caller with no credentials.

    `rows` is either a day file's `results` or `build_latest`'s per-endpoint
    list; both carry `class`, `url` and `detail`.

    Three numbers, because one would be dishonest:

    * `listed_tools` -- completed `initialize` AND returned a tool array. The
      strong claim, and the one to quote.
    * `handshake_only` -- completed `initialize` and then did not produce a
      list, minus the ones that asked for credentials at that second step. A
      handshake is not a tool call, so these are kept separate rather than
      folded in to make the headline bigger.
    * `hosts_*` -- the same thing counted per hostname. This matters more than
      it looks: three operators' fleets hold about 30% of the healthy
      endpoints, so an endpoint-weighted share is partly a measure of who runs
      the most servers. The host-deduplicated number is the one that survives
      that objection, and it is published next to the headline rather than
      buried.
    """
    total = len(rows)
    listed = [r for r in rows if r.get("class") == "healthy"]
    no_tools = [r for r in rows if r.get("class") == "no_tools"]
    refused = [r for r in no_tools if CRED_REFUSAL.search(r.get("detail") or "")]
    hosts = {}
    for r in rows:
        h = urllib.parse.urlsplit(r.get("url") or "").hostname or ""
        hosts.setdefault(h, set()).add(r.get("class"))
    host_listed = [h for h, cs in hosts.items() if "healthy" in cs]
    per_host = {}
    for r in listed:
        h = urllib.parse.urlsplit(r.get("url") or "").hostname or ""
        per_host[h] = per_host.get(h, 0) + 1
    top3 = sorted(per_host.values(), reverse=True)[:3]
    return {
        "endpoints": total,
        "listed_tools": len(listed),
        "listed_tools_pct": round(_pct(len(listed), total), 1),
        "handshake_only": len(no_tools) - len(refused),
        "handshake_refused_credentials": len(refused),
        "hosts": len(hosts),
        "hosts_listed_tools": len(host_listed),
        "hosts_listed_tools_pct": round(_pct(len(host_listed), len(hosts)), 1),
        "top3_host_share_of_listed_pct": round(_pct(sum(top3), len(listed)), 1),
        "tools_exposed": sum(r.get("tools") or 0 for r in listed),
        "note": "The probe sends no credentials of any kind -- no Authorization "
                "header, no API key, no cookie -- on any request, which is how "
                "the `gated` class is detected at all. `listed_tools` is "
                "therefore the number of endpoints that completed a real MCP "
                "handshake and returned their full tool list to an anonymous "
                "caller. This is a measurement, not a judgement: answering "
                "anonymously is correct for a public tool, and requiring "
                "credentials is correct behaviour rather than a defect. It is "
                "not a count of vulnerabilities and no endpoint is named here.",
    }


def drift_summary(servers, days):
    """Tool-contract movement over the window, or None if it was never tracked.

    None rather than a row of zeros. Fingerprinting started partway through
    this dataset's life, so every day file written before it has no opinion
    about drift at all — and "no server changed its tools" is a finding, while
    "we did not look" is not. Only a day that actually carried the count can
    contribute to either.
    """
    scored = [d for d in days if d.get("drifted") is not None]
    if not scored:
        return None
    moved = [s for s in servers if s.get("drift")]
    tot = {"added": 0, "removed": 0, "changed": 0}
    for s in moved:
        for k in tot:
            tot[k] += s["drift"].get(k) or 0
    return {
        "tracking_since": scored[0]["date"],
        "days_tracked": len(scored),
        # How many endpoints we hold a contract fingerprint for at all. The
        # denominator for every rate below: gated and unreachable endpoints
        # never list tools, so they can never drift and must not be counted in.
        "tracked": len([s for s in servers if s.get("schema_sig")]),
        "latest_day": scored[-1].get("drifted") or 0,
        "servers": len(moved),
        # Servers whose contract moved on more than one day in the window.
        # Worth separating: a tool description carrying a timestamp or a
        # session id drifts every single day and is a property of that server's
        # implementation, not news.
        "repeat": len([s for s in moved if (s.get("drift_days") or 0) > 1]),
        "tools": tot,
    }


def build_latest():
    """Fold the day files into per-endpoint current state and uptime."""
    files = day_files()[-HISTORY_DAYS:]
    if not files:
        return None
    days = [read_day(p) for p in files]
    days.sort(key=lambda d: d["date"])
    dates = [d["date"] for d in days]
    reg = load_registry() or {"list": []}
    meta = {}
    for e in reg["list"]:
        meta[e["name"] + "|" + e["url"]] = e

    servers = {}
    for d in days:
        for r in d["results"]:
            k = key_of(r)
            s = servers.setdefault(k, {"name": r["name"], "url": r["url"],
                                       "transport": r.get("transport") or "",
                                       "history": {}})
            s["history"][d["date"]] = {
                "c": r["class"], "ms": r.get("ms"),
                "tools": r.get("tools"), "http": r.get("http")}
            s["class"] = r["class"]
            s["ms"] = r.get("ms")
            s["tools"] = r.get("tools")
            s["http"] = r.get("http")
            s["protocol"] = r.get("protocol")
            s["server_name"] = r.get("server_name")
            s["bad_schema"] = r.get("bad_schema")
            s["via"] = r.get("via") or ""
            s["detail"] = r.get("detail") or ""
            if r.get("schema_sig"):
                s["schema_sig"] = r["schema_sig"]
                # Days on which we actually READ a contract for this endpoint.
                # One is a baseline and not a finding: "unchanged" needs two.
                s["schema_days"] = (s.get("schema_days") or 0) + 1
            if r.get("drift"):
                # Most recent wins, and the count is of DAYS on which the
                # contract moved, not of tools: a server that changes one tool
                # every day is a different animal from one that rewrote
                # everything once, and only the day count separates them.
                ev = dict(r["drift"])
                ev["date"] = d["date"]
                s["drift"] = ev
                s["drift_days"] = (s.get("drift_days") or 0) + 1
            if r["class"] == "healthy":
                s["last_healthy"] = d["date"]

    last30 = dates[-30:]
    last7 = dates[-7:]
    out = []
    for k, s in servers.items():
        h = s["history"]
        m = meta.get(k) or {}

        def _rate(window):
            seen = [h[x] for x in window if x in h]
            if not seen:
                return None
            ok = len([x for x in seen if x["c"] == "healthy"])
            return round(100.0 * ok / len(seen), 1)

        lat = [h[x]["ms"] for x in last30 if x in h and h[x].get("ms")]
        lat.sort()
        out.append({
            "key": k,
            "name": s["name"],
            "url": s["url"],
            "title": m.get("title") or "",
            "description": m.get("description") or "",
            "transport": s.get("transport") or m.get("transport") or "",
            "class": s.get("class"),
            "ms": s.get("ms"),
            "tools": s.get("tools"),
            "http": s.get("http"),
            "protocol": s.get("protocol"),
            "server_name": s.get("server_name"),
            "bad_schema": s.get("bad_schema"),
            # The tool contract: a digest of it, when it last moved, and on how
            # many of the days in this window. Absent, not zero, on a server we
            # have never seen list tools -- "we have no prior reading" and
            # "nothing changed" are different facts and the page says so.
            "schema_sig": s.get("schema_sig"),
            "schema_days": s.get("schema_days") or 0,
            "drift": s.get("drift"),
            "drift_days": s.get("drift_days") or 0,
            # Which transport actually answered, as opposed to the one the
            # publisher declared. They differ often enough to be worth keeping.
            "via": s.get("via") or "",
            "detail": s.get("detail") or "",
            "checks": len(h),
            "up7": _rate(last7),
            "up30": _rate(last30),
            "p50_ms": lat[len(lat) // 2] if lat else None,
            "p95_ms": lat[min(len(lat) - 1, int(len(lat) * 0.95))] if lat else None,
            "last_healthy": s.get("last_healthy"),
            # One character per day, oldest first, so a whole month of history
            # costs 30 bytes and a person can read the pattern at a glance.
            "strip": "".join(CLASS_CHAR.get(h[x]["c"], "?") if x in h else "."
                             for x in last30),
        })
    out.sort(key=lambda r: r["name"])
    counts = {c: 0 for c in CLASSES}
    for r in out:
        counts[r["class"]] = counts.get(r["class"], 0) + 1
    payload = {
        "generated": _utcnow(),
        "dates": last30,
        "latest_date": dates[-1],
        "days_of_history": len(dates),
        "endpoints": len(out),
        "counts": counts,
        # The same rows counted along the auth axis. See no_auth_summary.
        "no_auth": no_auth_summary(out),
        # Tool-contract movement. `None` until a probe has run twice with
        # fingerprinting on, and that is the point: a drift count of 0 would
        # otherwise be indistinguishable from never having looked.
        "drift": drift_summary(out, days),
        "runs": [{"date": d["date"], "endpoints": d["endpoints"],
                  "counts": d["counts"], "partial": d.get("partial", False),
                  "drifted": d.get("drifted"),
                  # Why a day's Probed column is below the census: some
                  # endpoints rate-limited us and some were not reached inside
                  # the pass's time box. Both are "we did not look", and the
                  # page has to be able to say so rather than leave a reader to
                  # read the shortfall as servers going away.
                  "throttled": d.get("throttled") or 0,
                  "not_reached": d.get("not_reached") or 0,
                  "no_auth": no_auth_summary(d["results"])}
                 for d in days],
        "servers": out,
    }
    STATE.mkdir(parents=True, exist_ok=True)
    LATEST_FILE.write_text(json.dumps(payload), encoding="utf-8")
    return payload


def load_latest():
    """The rolled-up view, rebuilt whenever a probe day is newer than it.

    latest.json is derived, never authoritative -- the day files are the
    record. Serving a cached roll-up that predates the newest day file would
    publish yesterday's verdicts under today's date, which on a registry whose
    entire claim is freshness is the worst available failure.
    """
    days = day_files()
    if LATEST_FILE.exists() and days:
        if LATEST_FILE.stat().st_mtime >= max(p.stat().st_mtime for p in days):
            return json.loads(LATEST_FILE.read_text(encoding="utf-8"))
    return build_latest()


# --------------------------------------------------------------------------
# the public surface
#
# The file budget is the binding constraint here, not effort: site/ holds 93,263
# files against Cloudflare Pages' 100,000-file cap (state/open-questions.md,
# 2026-09-02), so one static page per endpoint -- ~19,000 of them -- is simply
# not available. Per-endpoint pages and badges are therefore served by
# functions/mcp-health/server/[[path]].js and functions/mcp-health/badge/
# [name].js out of 256 shard files, which costs 260 files instead of 19,000 and
# produces the same URLs a static build would have. Two narrow routes rather
# than one /mcp-health/[[path]] catch-all, so they cannot shadow the static
# scoreboard, feed.json or the shards themselves.

SHARDS = 256


def _h31(name):
    h = 0
    for ch in name.encode("utf-8"):
        h = (h * 31 + ch) & 0xFFFFFFFF
    return h


def shard_of(name):
    return "{0:02x}".format(_h31(name) % SHARDS)


def slug_of(name):
    s = re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")
    return s or "server"


def unique_slugs(servers):
    """Give every registry NAME a slug no other name shares.

    `slug_of` lower-cases and collapses every run of punctuation, so it is not
    injective: `io.github.Zuga-luga/Zugabot` and `io.github.Zuga-luga/zugabot`
    are two separate registry entries that fold to one slug, and so would
    `a.b/c` and `a-b/c`. That matters because the page and the badge are keyed
    on the slug alone. Two names sharing one slug means one publisher's page
    carries another publisher's endpoint under the wrong heading, and one
    badge merges two different servers' verdicts -- so a dead server could
    show "up" on the strength of somebody else's.

    A name that shares its slug with nobody keeps that slug exactly: 16,000
    live URLs and any badge already embedded in a README must not move for a
    problem that affects a handful of them. Names that do share one each get a
    suffix from a hash of the full name, and the bare slug stays live as a
    disambiguation page listing them -- so an existing link still resolves to
    something true rather than to a 404.

    Note this is about two DIFFERENT names colliding. One name publishing
    several endpoints is not a collision: those rows belong on one page, which
    is what the shard's list-per-slug already gives them.

    Returns (name -> slug, bare slug -> [{name, slug}, ...] for the shared ones).
    """
    names_by_slug = {}
    for s in servers:
        names_by_slug.setdefault(slug_of(s["name"]), set()).add(s["name"])
    slugs, shared = {}, {}
    for base in sorted(names_by_slug):
        if len(names_by_slug[base]) == 1:
            slugs[list(names_by_slug[base])[0]] = base
    # Every bare slug is spoken for by the loop above or reserved for a
    # disambiguation page, and a suffixed slug could in principle be some other
    # server's real slug -- `x/y` and `x/y-8f4b` are both legal names. Merging
    # two servers onto one page is the whole bug, so take a free one.
    taken = set(slugs.values()) | set(names_by_slug)
    for base in sorted(names_by_slug):
        names = names_by_slug[base]
        if len(names) == 1:
            continue
        for n in sorted(names):
            h = _h31(n) & 0xFFFF
            sl = "{0}-{1:04x}".format(base, h)
            while sl in taken:
                h = (h + 1) & 0xFFFF
                sl = "{0}-{1:04x}".format(base, h)
            taken.add(sl)
            slugs[n] = sl
            shared.setdefault(base, []).append({"name": n, "slug": sl})
    return slugs, shared


def _pct(n, d):
    return 0.0 if not d else 100.0 * n / d


def _esc(s):
    return (str(s).replace("&", "&amp;").replace("<", "&lt;")
            .replace(">", "&gt;").replace('"', "&quot;"))


def one_per_host(rows, limit=40, per_host=1):
    """Take `limit` rows, at most `per_host` from any single hostname.

    A registry name is not an operator. 115 of this corpus's entries are one
    publisher's synthetic sensors and all 115 resolve to a single Cloudflare
    Worker; 17 more are one company's host. Any front-page table that takes the
    top N of an ordering shows that fleet N times over -- on 2026-09-08 the
    healthy table was 37 of 40 rows from two hosts, which reads as a ranking of
    two companies rather than a look at the corpus. Capping by host, not by
    publisher namespace, because the host is what a deployment actually is:
    one operator can legitimately run several distinct servers on separate
    hosts, and one host serving 115 names is one thing wearing 115 hats.
    """
    seen = {}
    out = []
    for s in rows:
        host = (urllib.parse.urlsplit(s.get("url") or "").hostname or "").lower()
        if seen.get(host, 0) >= per_host:
            continue
        seen[host] = seen.get(host, 0) + 1
        out.append(s)
        if len(out) >= limit:
            break
    return out


BROWSE_PER_PAGE = 250

# The slug rule, in the two languages that have to agree on it. `slug_of` is
# the Python one; this is the same regex for the browser, so somebody who knows
# their registry name can be sent to their own page without this site shipping
# a 15,000-name index to every visitor.
FIND_JS = """(function(){var f=document.getElementById('mcpfind');if(!f)return;
f.addEventListener('submit',function(e){e.preventDefault();
var v=(f.q.value||'').toLowerCase().replace(/[^a-z0-9]+/g,'-')
 .replace(/^-+|-+$/g,'');if(!v)return;
location.href='/mcp-health/server/'+encodeURIComponent(v)+'/';});})();"""


def find_form_html():
    """A name -> page lookup that needs no index and no server.

    The badge is this experiment's growth loop and a publisher can only embed
    it once they have found their own page. Until 2026-09-08 the only way in
    was to already know the slug rule, which was written down nowhere: 16,059
    of the 16,099 per-server pages had no link from anywhere on the site.
    """
    return (
        '<h2 id="find">Find a server</h2>'
        '<form id="mcpfind" action="/mcp-health/list/1/" method="get">'
        '<label for="mcpq">Its name on the registry</label> '
        '<input id="mcpq" name="q" type="text" size="30" spellcheck="false" '
        'autocapitalize="off" autocorrect="off" '
        'placeholder="io.github.owner/server-name" '
        'style="font:inherit;padding:.35rem .5rem;border:1px solid #bbb;'
        'border-radius:4px;max-width:100%"> '
        '<button type="submit" style="font:inherit;padding:.35rem .8rem;'
        'border:1px solid #bbb;border-radius:4px;cursor:pointer">'
        'Go to its page</button></form>'
        '<p class="prose">Every probed endpoint has a page and a badge, not '
        'just the ones in the tables on this page. The address is the '
        'registry name '
        'lower-cased with every run of punctuation turned into a single '
        'hyphen &mdash; <code>io.github.Owner/My_Server</code> is '
        '<code>/mcp-health/server/io-github-owner-my-server/</code> &mdash; or '
        '<a href="/mcp-health/list/1/">browse all of them A&ndash;Z</a>. The '
        'box needs JavaScript; the rule above and the A&ndash;Z pages do '
        'not.</p>'
        '<script>' + FIND_JS + '</script>')


def browse_groups(data, slugs):
    """One entry per registry name, A-Z, with the best verdict among its rows.

    Grouped by name rather than by endpoint because the page and the badge are
    keyed on the name: a server publishing three URLs is one row here and one
    page there. "Best" is the same ranking the badge Worker uses, for the same
    reason -- a project with one dead spare URL is not a down project.
    """
    rank = {c: i for i, c in enumerate(CLASSES)}
    by_name = {}
    for s in data["servers"]:
        by_name.setdefault(s["name"], []).append(s)
    out = []
    for name in sorted(by_name, key=lambda x: (x.lower(), x)):
        rows = by_name[name]
        best = min(rows, key=lambda r: rank.get(r.get("class"), 99))
        out.append({
            "name": name,
            "slug": slugs[name],
            "class": best.get("class") or "unreachable",
            "endpoints": len(rows),
            "tools": best.get("tools"),
            "last_healthy": best.get("last_healthy") or "",
        })
    return out


def browse_pages_html(entries, k, pages, total_names, data):
    n = len(entries)
    first, last = entries[0]["name"], entries[-1]["name"]
    h = ["<h1>Every MCP server we probe, {0} &ndash; {1}</h1>".format(
        _esc(first), _esc(last))]
    h.append('<p class="hero">Page {0} of {1}. {2} of the {3:,} names on the '
             'official MCP registry that publish a connect URL, in alphabetical '
             'order. Each has its own page and an embeddable badge. Last '
             'probed {4}.</p>'.format(k, pages, n, total_names,
                                      data["latest_date"]))
    h.append('<p class="prose">The verdict is the most recent probe, and it is '
             'not a score. <em>Needs credentials</em> is not a fault; it means '
             'the endpoint wants an account, which is a reasonable thing for it '
             'to want. A name publishing several endpoints shows the best '
             'result among them, and its page lists each one separately.</p>')
    h.append('<table class="facts"><thead><tr><th>Server</th><th>Most recent '
             'probe</th><th>Tools</th><th>Last seen healthy</th></tr></thead>'
             '<tbody>')
    for e in entries:
        h.append('<tr><td><a href="/mcp-health/server/{0}/">{1}</a>{2}</td>'
                 '<td>{3}</td><td>{4}</td><td>{5}</td></tr>'.format(
                     _esc(e["slug"]), _esc(e["name"]),
                     "" if e["endpoints"] == 1 else
                     ' <span class="url">{0} endpoints</span>'.format(
                         e["endpoints"]),
                     _esc(CLASS_LABEL[e["class"]]),
                     e["tools"] if e["tools"] is not None else "&mdash;",
                     _esc(e["last_healthy"]) or "not in the days probed"))
    h.append("</tbody></table>")
    h.append(pager_html(k, pages))
    h.append(find_form_html())
    h.append('<p class="prose"><a href="/mcp-health/">The method, the daily '
             'totals and what each verdict means</a> &middot; '
             '<a href="/mcp-health/feed.json">feed.json</a> &middot; to be '
             'removed from the probe, write to '
             '<a href="mailto:{0}">{0}</a>.</p>'.format(CONTACT))
    return "\n".join(h)


def pager_html(k, pages):
    bits = []
    for i in range(1, pages + 1):
        bits.append(str(i) if i == k else
                    '<a href="/mcp-health/list/{0}/">{0}</a>'.format(i))
    return ('<p class="prose"><strong>Pages:</strong> {0}</p>'
            .format(" &middot; ".join(bits)))


def scoreboard_html(data, site):
    import render as R                     # lazy: render imports this module

    n = data["endpoints"]
    c = data["counts"]
    latest = data["latest_date"]
    # Link to the same slug the shard is keyed on, not to slug_of(name) --
    # for a name that shares a slug with another server those are different.
    slugs = unique_slugs(data["servers"])[0]
    h = []
    h.append("<h1>Is that MCP server actually up?</h1>")
    h.append('<p class="hero">Every server on the official MCP registry that '
             'publishes a connect URL, probed once a day with a real '
             '<code>initialize</code> handshake and a <code>tools/list</code> '
             'call. {0:,} endpoints, last checked {1}.</p>'.format(
                 n, R.time_html(latest) or latest))
    h.append('<table class="facts"><thead><tr><th>What happened</th>'
             '<th>Endpoints</th><th>Share</th></tr></thead><tbody>')
    for cl in CLASSES:
        h.append('<tr><td>{0}</td><td>{1:,}</td><td>{2:.1f}%</td></tr>'.format(
            _esc(CLASS_LABEL[cl]), c.get(cl, 0), _pct(c.get(cl, 0), n)))
    h.append("</tbody></table>")

    broken = c.get("not_mcp", 0) + c.get("no_tools", 0)
    h.append('<p class="prose"><strong>{0:,} endpoints ({1:.1f}%) answer HTTP '
             'but cannot complete an MCP session.</strong> That is the finding '
             'this exists for. A reachability check — DNS, a ping, an HTTP 200 '
             '— marks every one of them healthy. Only a real handshake sees '
             'it.</p>'.format(broken, _pct(broken, n)))
    # The page above answers "is someone else's server up". The commonest
    # reason a reader is here is that their own is not, and that question had
    # no surface until 2026-09-15.
    h.append('<p class="prose"><strong>Is it your own server that will not '
             'connect?</strong> The same check runs locally, against one '
             'address or one command, and names which of three causes you have '
             '— a missing launcher, a dependency that moved, or a transport '
             'mismatch. <a href="/mcp-health/diagnose/">Why does my MCP server '
             'say “connection closed”?</a></p>')

    na = data.get("no_auth")
    if na:
        h.append("<h2>How many answer a stranger</h2>")
        h.append(
            '<p class="prose"><strong>{0:,} endpoints ({1:.1f}%) returned '
            'their full tool list to a caller sending no credentials at '
            'all.</strong> Between them they described {2:,} callable tools. '
            'This probe has never held an API key, a token or a cookie for '
            'anybody — that is how "needs credentials" gets detected in the '
            'first place — so every endpoint in the first row of the table '
            'above is one that told an anonymous stranger what it can '
            'do.</p>'.format(na["listed_tools"], na["listed_tools_pct"],
                             na["tools_exposed"]))
        h.append(
            '<p class="prose"><strong>Counted by operator instead, it is '
            '{0:,} of {1:,} hostnames ({2:.1f}%).</strong> Prefer that number '
            'if you are quoting one. The endpoint share is partly a measure of '
            'who runs the most servers — the three largest fleets alone hold '
            '{3:.1f}% of the endpoints that answered — and one operator '
            'standing up a thousand endpoints should not move a finding about '
            'the ecosystem.</p>'.format(
                na["hosts_listed_tools"], na["hosts"],
                na["hosts_listed_tools_pct"],
                na["top3_host_share_of_listed_pct"]))
        h.append(
            '<p class="prose"><strong>This is not a security score and it '
            'names nobody.</strong> Answering an anonymous caller is correct '
            'behaviour for a public search tool, a docs server or an open '
            'dataset, and requiring credentials stays what it is everywhere '
            'else on this site: correct behaviour, not a defect. The largest '
            'published survey of the question takes the same view. TrendAI\'s '
            '<a href="https://www.trendaisecurity.com/en/resources-insights/'
            'research/stars-dont-save-you-popularity-is-not-security-in-the-'
            'mcp-ecosystem">“Stars Don’t Save You”</a> (24 June 2026) audited '
            '9,695 MCP servers and <em>deliberately set aside</em> 3,573 of '
            'them that had been marked unsecured “solely because of a lack of '
            'authentication”, because it “did not consider it alarming enough '
            'to flag an MCP server on its own”. Its headline 4,982 issues are '
            'the ones that survived that exclusion.</p>')
        h.append(
            '<p class="prose"><strong>And it is not the same measurement as '
            'theirs.</strong> TrendAI crawled four directories — GitHub, '
            'Glama, Lobehub and PulseMCP — and read the servers\' published '
            'source. This page opens a live connection to a URL on the '
            'official registry and sees what actually answers. Source code '
            'can show an auth check that a deployment never enforces, and a '
            'running endpoint can demand credentials its repository never '
            'mentions. Neither number substitutes for the other, and the two '
            'should not be added, compared or quoted as one finding.</p>')
        h.append(
            '<p class="prose">A further {0:,} endpoints completed the '
            '<code>initialize</code> handshake anonymously and then produced '
            'no tool list; those are <em>not</em> counted above, because a '
            'handshake is not a tool call. Separately, {1:,} completed the '
            'handshake and then asked for credentials at '
            '<code>tools/list</code> — a partial gate the five-way table '
            'cannot show, since it files them under "answered, no tool '
            'list".</p>'.format(na["handshake_only"],
                                na["handshake_refused_credentials"]))

    dr = data.get("drift")
    if dr and dr.get("days_tracked", 0) > 1:
        h.append("<h2>What changed under you</h2>")
        h.append(
            '<p class="prose"><strong>{0:,} of the {1:,} endpoints that list '
            'tools changed their tool contract on the most recent pass.</strong> '
            'Uptime is the loud failure: the call errors and somebody notices '
            'within the hour. A tool being renamed, a required field being '
            'added, a description being rewritten — none of those break the '
            'connection. The session still completes, this page still says '
            '“answered, tools listed”, and the failure reaches a person as '
            '“the assistant is being weird today”. So on every probe that '
            'returns a tool list we hash each tool and diff it against the '
            'last list we held for that same endpoint.</p>'.format(
                dr.get("latest_day") or 0, dr.get("tracked") or 0))
        h.append(
            '<p class="prose">Over the {0} day(s) tracked so far, {1:,} '
            'endpoints have moved at least once: {2:,} tools added, {3:,} '
            'removed and {4:,} changed in place. {5}</p>'.format(
                dr.get("days_tracked"), dr.get("servers") or 0,
                (dr.get("tools") or {}).get("added") or 0,
                (dr.get("tools") or {}).get("removed") or 0,
                (dr.get("tools") or {}).get("changed") or 0,
                ('{0:,} of them moved on more than one day, which is usually a '
                 'property of the server rather than news — a description '
                 'carrying a timestamp or a session id drifts every time it is '
                 'read.'.format(dr.get("repeat") or 0)) if dr.get("repeat")
                else 'None of them moved on more than one day.'))
        moved = one_per_host(
            sorted([s for s in data["servers"]
                    if s.get("drift")
                    and s["drift"].get("date") == data["latest_date"]],
                   key=lambda s: (-(s["drift"].get("added", 0)
                                    + s["drift"].get("removed", 0)
                                    + s["drift"].get("changed", 0)),
                                  s["name"])), limit=25)
        if moved:
            h.append('<table class="facts"><thead><tr><th>Server</th>'
                     '<th>Added</th><th>Removed</th><th>Changed</th>'
                     '<th>Since</th></tr></thead><tbody>')
            for s in moved:
                d = s["drift"]
                h.append('<tr><td><a href="/mcp-health/server/{0}/">{1}</a>'
                         '</td><td>{2}</td><td>{3}</td><td>{4}</td><td>{5}'
                         '</td></tr>'.format(
                             _esc(slugs[s["name"]]), _esc(s["name"]),
                             d.get("added") or 0, d.get("removed") or 0,
                             d.get("changed") or 0, _esc(d.get("since") or "—")))
            h.append("</tbody></table>")
        h.append(
            '<p class="prose"><strong>This stores no schema.</strong> What is '
            'kept per tool is two short hex digests — one of the name, one of '
            'the name with its description and input schema — and nothing '
            'else. A digest is a one-way function of somebody else\'s text, '
            'not a copy of it, so the count above can be published without '
            'republishing a word of anyone\'s documentation. It is also why '
            'this page can tell you <em>that</em> a contract moved and how '
            'much of it, but not what it now says: for that, ask the server.'
            '</p>')

    h.append(find_form_html())

    if len(data["runs"]) > 1:
        h.append("<h2>Day by day</h2>")
        h.append('<table class="facts"><thead><tr><th>Date</th>'
                 '<th>Probed</th>' + "".join(
                     "<th>{0}</th>".format(_esc(CLASS_LABEL[x]))
                     for x in CLASSES) + "</tr></thead><tbody>")
        for r in reversed(data["runs"]):
            h.append("<tr><td>{0}</td><td>{1:,}</td>{2}</tr>".format(
                r["date"], r["endpoints"],
                "".join("<td>{0:,}</td>".format(r["counts"].get(x, 0))
                        for x in CLASSES)))
        h.append("</tbody></table>")
        last = data["runs"][-1]
        missing = (last.get("throttled") or 0) + (last.get("not_reached") or 0)
        if missing:
            h.append('<p class="prose"><strong>Probed is lower than the number '
                     'of registered endpoints, and the difference is ours, not '
                     'theirs.</strong> On {0} we did not get an answer from '
                     '{1:,} endpoints: {2:,} rate-limited us and {3:,} were '
                     'not reached before the pass ran out of time. An endpoint '
                     'we did not look at is absent from that day rather than '
                     'counted as broken, so it draws as a gap. Being refused '
                     'is not evidence about a server — it is evidence about '
                     'our own request.</p>'.format(
                         last["date"], missing, last.get("throttled") or 0,
                         last.get("not_reached") or 0))

    servers = data["servers"]
    healthy = [s for s in servers if s["class"] == "healthy"]
    # Not sorted by latency. The paragraph below this table says latency is not
    # a ranking, and a table headed "fastest first" said the opposite on the
    # same screen; sorting on it also selects for servers that do the least
    # work, which is how a rival observatory's 40ms synthetic sensors came to
    # hold 27 of these 40 rows.
    with_tools = [s for s in healthy if s.get("tools")]
    healthy_big = one_per_host(
        sorted(with_tools,
               key=lambda s: (-s["tools"], s.get("p50_ms") or 0, s["name"])))
    median_tools = (sorted(s["tools"] for s in with_tools)[len(with_tools) // 2]
                    if with_tools else 0)
    if healthy_big:
        h.append("<h2>Some of what answered today</h2>")
        h.append('<p class="prose">{2} of the {0:,} endpoints that completed '
                 'a handshake on the most recent pass, largest tool list first, '
                 'and at most one per host so that one operator\'s fleet cannot '
                 'fill the table. Neither column is a score. Tool count is what '
                 '<code>tools/list</code> returned, not a measure of how useful '
                 'a server is; the Median column is the round-trip for a full '
                 '<code>initialize</code>, and a server doing real work on '
                 'connect is not worse than one that does nothing. These are '
                 'the largest, not the shape of the corpus: half the servers '
                 'that answered list {1} tools or fewer.</p>'
                 .format(len(healthy), median_tools, len(healthy_big)))
        # The uptime window is however many days have actually been probed. A
        # header reading "7d" over a single day's data claims a week of
        # evidence for a number computed from one observation.
        h.append('<table class="facts"><thead><tr><th>Server</th>'
                 '<th>Tools</th><th>Median</th><th>Uptime ({0}d)</th>'
                 '</tr></thead><tbody>'.format(
                     min(7, data.get("days_of_history") or 1)))
        for s in healthy_big:
            h.append('<tr><td><a href="/mcp-health/server/{0}/">{1}</a></td>'
                     '<td>{2}</td><td>{3}</td><td>{4}</td></tr>'.format(
                         _esc(slugs[s["name"]]), _esc(s["name"]),
                         s.get("tools") if s.get("tools") is not None else "—",
                         "{0} ms".format(s["p50_ms"]) if s.get("p50_ms")
                         is not None else "—",
                         "{0:.0f}%".format(s["up7"]) if s.get("up7") is not None
                         else "—"))
        h.append("</tbody></table>")

    stale = [s for s in servers
             if s["class"] != "healthy" and s.get("last_healthy")]
    stale.sort(key=lambda s: s["last_healthy"])
    # Same cap, same reason: one host having a bad afternoon publishes as many
    # endpoints as it lists, and would otherwise be the whole table.
    stale = one_per_host(stale)
    if stale:
        h.append("<h2>Answered once, not today</h2>")
        h.append('<p class="prose">These completed a handshake on a day we '
                 'probed and did not on the most recent one, at most one per '
                 'host. This is the field that matters — not a score, which '
                 'rots, but when the endpoint was last seen working.</p>')
        h.append('<table class="facts"><thead><tr><th>Server</th>'
                 '<th>Last seen healthy</th><th>Today</th></tr>'
                 '</thead><tbody>')
        for s in stale:
            h.append('<tr><td><a href="/mcp-health/server/{0}/">{1}</a></td>'
                     '<td>{2}</td><td>{3}</td></tr>'.format(
                         _esc(slugs[s["name"]]), _esc(s["name"]),
                         s["last_healthy"], _esc(CLASS_LABEL[s["class"]])))
        h.append("</tbody></table>")

    h.append("<h2>How this is measured</h2>")
    h.append(
        '<p class="prose">Once a day, each endpoint gets a JSON-RPC '
        '<code>initialize</code> and — if that succeeds — a '
        '<code>tools/list</code>. The user agent identifies itself and carries '
        'a contact address; there is a minimum gap between two requests to the '
        'same host and no retry storm. Every endpoint probed is a URL its '
        'publisher listed on a public registry so that clients would connect '
        'to it.</p>')
    h.append(
        '<p class="prose"><strong>Both remote transports.</strong> MCP has two. '
        'Streamable HTTP is one POST per message, at protocol version '
        '<code>{0}</code>; the original transport (<code>2024-11-05</code>) is '
        'two channels — the client opens the URL with '
        '<code>Accept: text/event-stream</code>, the server names a separate '
        'POST address in an <code>event: endpoint</code> frame, and replies '
        'come back down the open stream. A POST alone cannot complete a '
        'handshake with the second kind however healthy the server is. '
        '<strong>This probe got that wrong until 2026-09-08</strong> and '
        'published 143 servers as "answers HTTP, not MCP" that were nothing of '
        'the kind — 104 of them working, 36 wanting credentials. They were '
        're-probed and corrected the same day, and each endpoint is now tried '
        'on whichever transport its publisher declared, with the other as a '
        'fallback.</p>'.format(PROTOCOL_VERSION))
    h.append(
        '<p class="prose"><strong>Deliberately not a score.</strong> No 0–100 '
        'number, no "dead" flag. The registry considered a publisher-declared '
        'health field in 2026 and withdrew it, because a static score rots and '
        'because nothing was actually dead — the failure is protocol-level, in '
        'servers that answer HTTP perfectly well. "Needs credentials" is not a '
        'fault: it means we could not complete a handshake without an account, '
        'which is all we can honestly say. Our own server is probed by this '
        'same code.</p>')
    h.append(
        '<p class="prose"><strong>Corroboration.</strong> Three independent '
        'measurements of this population between 2026-07 and 2026-09 — MCP '
        'registry issue #1487, <code>siliroid/mcp-endpoint-census</code>, and '
        '<code>yassinht/mcp-observatory</code> — found the same shape of '
        'failure at 8–12%, using different samples and methods. This is a '
        'continuously-running, publicly-readable version of that check.</p>')
    # Added 2026-09-10. This page previously said "*the* continuously-running
    # version", which was a uniqueness claim and was wrong: mymcptools.com has
    # run the same handshake since at least 2026-07-02. Naming them costs us
    # nothing -- the overlap agrees with us on every endpoint, which is better
    # evidence for this prober than anything we could assert about it -- and a
    # sole-authorship claim is the kind a reader disproves in one search.
    # Working of it in ops/distribution/E6/mymcptools.md.
    # The last sentence of this paragraph used to read "They also do one thing
    # we do not: they hash each tool schema and flag it when it changes under
    # you." That was true until drift tracking shipped, and it is held to the
    # data rather than to the deploy date: the credit stands unchanged until a
    # second fingerprinted probe exists to compare, because a page claiming a
    # capability whose output is not on it would be the same kind of untrue.
    drift_live = bool((data.get("drift") or {}).get("days_tracked", 0) > 1)
    h.append(
        '<p class="prose"><strong>We are not the only ones probing.</strong> '
        '<a href="https://mymcptools.com/developers" rel="nofollow">'
        'mymcptools.com</a> publishes live handshake results too, and has a '
        'longer run of them than we do — theirs go back to 2026-07-02. Theirs '
        'is a curated catalogue: of its 2,440 listings, 44 have a remote '
        'endpoint that can be probed at all, the rest being local installs '
        'with no address. Ours is a census of every endpoint on the official '
        'registry. Where the two overlap, they agree — on 2026-09-09, 26 of '
        'their endpoints matched ours and all 26 verdicts were the same. '
        + ('They hash each tool schema and flag it when it changes under you, '
           'and they were doing it before we were; that is where the section '
           'above came from. <a href="https://registry.modelcontextprotocol.'
           'io/v0/servers?search=mcp-uptime" rel="nofollow">'
           '<code>io.github.operatorsheets/mcp-uptime</code></a>, on the '
           'official registry since 2026-08-01, offers the same check as a '
           'tool you can call.'
           if drift_live else
           'They also do one thing we do not: they hash each tool schema and '
           'flag it when it changes under you.')
        + '</p>')
    # Added 2026-09-30. The paragraph above named two comparables. There are at
    # least four, and both of the missing ones were confirmed by direct fetch
    # this morning, not from a search snippet: RoninForge's State of MCP
    # (roninforge.org/data/state-of-mcp/, five dated censuses, latest 2026-09-13,
    # re-read live today off their Atom feed) and MCP Queen (mcpqueen.com,
    # 18,849 indexed / 9,326 graded live on 2026-09-30, already known internally
    # from a 2026-07-15 Show HN but never folded into this page). Working of it
    # in ops/distribution/E6/roninforge-state-of-mcp.md.
    #
    # Two things this is careful about.
    #
    # Their numbers are hardcoded, ours are not. A dated census is immutable and
    # theirs says so out loud ("each census supersedes the last without deleting
    # it"), so a number with 2026-09-13 attached to it cannot rot; a number with
    # no date on it would become a tripwire on somebody else's release cadence.
    # Our own side of every comparison is read out of `data` for the same reason.
    #
    # It does not claim their numbers agree with ours, because they do not: 22.5%
    # dead-or-broken there against 7.3% broken here. The gap is what each side
    # counts, and saying so is the whole point of naming them -- a reader who
    # opens both pages will see the gap in ten seconds, and a comparison that
    # quietly picked our own framing would be the kind of claim that gets
    # disproved in one click. The one near-like-for-like is flagged as near.
    probed = data["endpoints"]
    names = len(set(s["name"] for s in data["servers"]))
    unreach = _pct(data["counts"].get("unreachable", 0), probed)
    # Same definition of "broken" the table above this uses -- not_mcp + no_tools
    # -- so the page does not carry two numbers for one phrase.
    broken_pct = _pct(data["counts"].get("not_mcp", 0)
                      + data["counts"].get("no_tools", 0), probed)
    h.append(
        '<p class="prose"><strong>And two more, one of them casting a wider '
        'net than ours.</strong> RoninForge\'s '
        '<a href="https://roninforge.org/data/state-of-mcp/" rel="nofollow">'
        'State of MCP</a> checked every server on the registry — all 31,538 of '
        'them in its 2026-09-13 census: 80.6% healthy, 14.8% degraded, 3.8% '
        'dead. Wider, because a server there can be graded without anybody ever '
        'connecting to it: a GitHub repository that has rotted — gone or '
        'archived, 3,587 of 24,383, 14.7% — or a package that was never '
        'published counts toward its '
        'verdict, so servers that publish no connect URL at all still get one. '
        'This page only ever grades an endpoint we opened a session with, and '
        'says nothing whatever about the other kind. That cuts both ways — '
        '"healthy" there can mean the packaging is intact, and here it always '
        'means a completed MCP session. Slower, though: five censuses in about '
        'ten weeks, the newest dated 2026-09-13, against {0:,} endpoints '
        're-probed every day for {1} days here — and their 2026-09-13 census '
        'found 19,150 remote-bearing servers where this page probes {2:,} names '
        'today, which is what a dated snapshot costs you. '
        'Their headline rates are not ours and should '
        'not be read as ours: 22.5% of their remote-bearing cohort is '
        'dead-or-broken where {3:.1f}% of our endpoints answer HTTP without '
        'completing a session, and the distance is almost entirely what each '
        'verdict is allowed to be earned from. The nearest thing to a '
        'like-for-like is a declared remote that cannot be reached at all: '
        '9.8% there on 2026-09-13, {4:.1f}% here today — and even that is not '
        'exact, because their bucket counts an HTTP 404 as down where ours '
        'files it under "answers HTTP, not MCP". Their data is CC BY 4.0 and '
        'wants attribution; this is CC0 and does not. '
        '<a href="https://mcpqueen.com/" rel="nofollow">MCP Queen</a> is the '
        'other: 18,849 servers indexed and 9,326 graded live when we read it '
        'on 2026-09-30, with its own dated report putting one remote server in '
        'six dead in July 2026.</p>'.format(
            probed, data["days_of_history"], names, broken_pct, unreach))
    # Added 2026-10-01. A fifth comparable, and the only one that is not
    # measuring the same object we are -- which is the whole reason it gets a
    # paragraph instead of a clause in the one above. lastseen.dev grades each
    # server's linked GitHub REPOSITORY on commit age; this page grades an
    # ENDPOINT with a real handshake. Working in ops/distribution/E6/
    # lastseen-dev.md.
    #
    # The trap this paragraph exists to avoid: their headline (21.3% "not
    # maintained") and ours (dead-or-broken) LOOK comparable and are not. Two
    # numbers about two different objects placed side by side would read as
    # agreement or disagreement to someone with both tabs open, and they could
    # not detect the error from the pages -- worse than a wrong number they can
    # check. So the paragraph states the distinction, gives the two worked
    # cases, and then earns it with a measured disagreement rate rather than
    # asserting that the distinction matters.
    #
    # Their figures are hardcoded WITH the census date for the same reason
    # RoninForge's are: a dated census is immutable, an undated one is a
    # tripwire on their release cadence. The disagreement rate is OUR
    # measurement, so it carries our date, its n, and its Wilson interval --
    # and the caveat that the sample is drawn from an alphabetic prefix of the
    # registry rather than the whole of it, because the registry throttled the
    # re-crawl. Not quoted as a population figure anywhere.
    #
    # Deliberately absent: the defect found in their published Zenodo dump.
    # That is a dispute to send under their own published process, not copy to
    # publish. Publishing first would make a bug report an accusation. If they
    # correct it, the dated row in their public dispute log is the citable
    # thing and that is worth a line here; if they decline, their answer
    # changes what the line should say. Either way, after their 5 business
    # days, not before.
    h.append(
        '<p class="prose"><strong>And a fifth that is not measuring the same '
        'thing at all.</strong> '
        '<a href="https://lastseen.dev/" rel="nofollow">lastseen.dev</a> '
        'publishes a dated MCP census — 28,824 server repositories on '
        '2026-07-22, of which it called 70.7% alive, 7.8% dormant, 9.3% '
        'abandoned and 12.0% deleted, headlining 21.3% "not maintained" — '
        'with a Zenodo DOI, Sigstore-signed releases and a published '
        'correction process. <strong>Do not read that 21.3% against anything '
        'on this page.</strong> It grades a <em>repository</em> on how long '
        'ago somebody last committed to it; this page grades an '
        '<em>endpoint</em> on whether it completes an MCP session right now. '
        'A finished server nobody has touched in fourteen months answers '
        'every handshake perfectly — abandoned to them, healthy here. A '
        'server committed to last week whose deployment has lapsed or whose '
        'TLS has expired is alive to them and unreachable here. '
        'That is not a quibble, and rather than assert it we measured it: on '
        '{0} servers that both registers graded on 2026-10-01, '
        '<strong>25.6% of the repositories they call alive are endpoints this '
        'page cannot get a session out of</strong> (86 of 336, 95% CI '
        '21.2–30.5), and <strong>27.7% of the repositories they call '
        'abandoned, deleted or archived answer a clean handshake here</strong> '
        '(36 of 130, 95% CI 20.7–35.9) — 26.2% disagreement in one direction '
        'or the other. Neither register is wrong in those cases; they are '
        'answers to different questions, and which one you want depends on '
        'whether you are asking "will this still be maintained next year" or '
        '"can my agent connect to it this morning". The overlap is small — '
        'about half the servers we sampled are not subjects in their register '
        'at all, since a server that declares no repository gives them '
        'nothing to watch, exactly as a server that declares no connect URL '
        'gives us nothing to probe — and it is drawn from an alphabetic slice '
        'of the registry rather than all of it, so treat it as a rate and not '
        'as a population count. Their data is CC BY 4.0; this is CC0. Their '
        'census, with its own method written out, is '
        '<a href="https://lastseen.dev/reports/mcp-mortality-2026-07/" '
        'rel="nofollow">here</a> — it is worth reading, and it is more careful '
        'about the epistemics of dated observation than this page is.'
        '</p>'.format(491))
    h.append(
        '<p class="prose"><strong>Take the data.</strong> '
        '<a href="/mcp-health/feed.json">feed.json</a> is every endpoint with '
        'its class, latency, tool count, 7/30-day uptime and tool-contract '
        'digest. A per-server page '
        'is <code>/mcp-health/server/&lt;name&gt;/</code> and a badge you can '
        'embed is <code>/mcp-health/badge/&lt;name&gt;.svg</code>. Free, '
        'no key, no rate limit. If you run a directory and want this as a feed, '
        'or you want your server removed from the probe, write to '
        '<a href="mailto:{0}">{0}</a>.</p>'.format(CONTACT))
    # `id="licence"` is a real anchor and has to stay one: it is the URL the
    # JSON-LD `license` field claims, and the same anchor every asset page
    # uses, so a reuser who knows the site's shape can find it without reading.
    h.append('<p class="meta" id="licence"><strong>Licence.</strong> {0}</p>'
             .format(_esc(LICENCE)))
    return "\n".join(h)


def scoreboard_md(data, site):
    n = data["endpoints"]
    c = data["counts"]
    base = site["base_url"].rstrip("/")
    L = ["# Is that MCP server actually up?", "",
         "Every server on the official MCP registry that publishes a connect "
         "URL, probed once a day with a real `initialize` handshake and a "
         "`tools/list` call.", "",
         "- Endpoints: {0:,}".format(n),
         "- Last checked: {0}".format(data["latest_date"]),
         "- Days of history: {0}".format(data["days_of_history"]), ""]
    L.append("| What happened | Endpoints | Share |")
    L.append("|---|---|---|")
    for cl in CLASSES:
        L.append("| {0} | {1:,} | {2:.1f}% |".format(
            CLASS_LABEL[cl], c.get(cl, 0), _pct(c.get(cl, 0), n)))
    broken = c.get("not_mcp", 0) + c.get("no_tools", 0)
    L += ["",
          "{0:,} endpoints ({1:.1f}%) answer HTTP but cannot complete an MCP "
          "session. A reachability check marks every one of them healthy; only "
          "a real handshake sees it.".format(broken, _pct(broken, n)), ""]
    na = data.get("no_auth")
    if na:
        L += ["## How many answer a stranger", "",
              "{0:,} endpoints ({1:.1f}%) returned their full tool list to a "
              "caller sending no credentials at all, describing {2:,} callable "
              "tools between them. This probe has never held an API key, a "
              "token or a cookie for anybody -- that is how \"needs "
              "credentials\" is detected at all.".format(
                  na["listed_tools"], na["listed_tools_pct"],
                  na["tools_exposed"]), "",
              "Counted by operator instead, {0:,} of {1:,} hostnames "
              "({2:.1f}%). Prefer that number if you are quoting one: the "
              "three largest fleets alone hold {3:.1f}% of the endpoints that "
              "answered.".format(
                  na["hosts_listed_tools"], na["hosts"],
                  na["hosts_listed_tools_pct"],
                  na["top3_host_share_of_listed_pct"]), "",
              "Not a security score, and nobody is named. Answering an "
              "anonymous caller is correct for a public tool, and requiring "
              "credentials is correct behaviour rather than a defect. The "
              "largest published survey takes the same view: TrendAI's "
              "\"Stars Don't Save You\" (24 June 2026) audited 9,695 MCP "
              "servers and deliberately set aside 3,573 marked unsecured "
              "\"solely because of a lack of authentication\", because it "
              "\"did not consider it alarming enough to flag an MCP server on "
              "its own\".", "",
              "It is also a different measurement. TrendAI crawled four "
              "directories (GitHub, Glama, Lobehub, PulseMCP) and read the "
              "servers' published source; this page opens a live connection "
              "to a URL on the official registry. Source code can show an "
              "auth check a deployment never enforces, and a running endpoint "
              "can demand credentials its repository never mentions. The two "
              "numbers should not be added or quoted as one finding.", "",
              "A further {0:,} endpoints completed the handshake anonymously "
              "and produced no tool list -- not counted above, because a "
              "handshake is not a tool call -- and {1:,} completed it and then "
              "asked for credentials at `tools/list`.".format(
                  na["handshake_only"],
                  na["handshake_refused_credentials"]), ""]
    dr = data.get("drift")
    if dr and dr.get("days_tracked", 0) > 1:
        L += ["## What changed under you", "",
              "{0:,} of the {1:,} endpoints that list tools changed their tool "
              "contract on the most recent pass. Downtime is the loud failure; "
              "a renamed tool, a newly-required field or a rewritten "
              "description breaks nothing, completes the session, and reaches "
              "a person as \"the assistant is being weird today\". On every "
              "probe that returns a tool list we hash each tool's name, "
              "description and input schema and diff that against the last "
              "list held for the same endpoint.".format(
                  dr.get("latest_day") or 0, dr.get("tracked") or 0), "",
              "Over {0} day(s) tracked, {1:,} endpoints have moved at least "
              "once: {2:,} tools added, {3:,} removed, {4:,} changed in place; "
              "{5:,} moved on more than one day, which is usually a property "
              "of the server (a description carrying a timestamp) rather than "
              "news.".format(
                  dr.get("days_tracked"), dr.get("servers") or 0,
                  (dr.get("tools") or {}).get("added") or 0,
                  (dr.get("tools") or {}).get("removed") or 0,
                  (dr.get("tools") or {}).get("changed") or 0,
                  dr.get("repeat") or 0), "",
              "No schema is stored. Per tool we keep two short hex digests and "
              "nothing else, so the count can be published without "
              "republishing a word of anybody's documentation -- and so this "
              "can tell you that a contract moved and how much of it, but not "
              "what it now says.", ""]
    L += ["## Your own server will not connect?", "",
          "The same check runs locally against one address or one command and "
          "names which of three causes it is -- a missing launcher, a "
          "dependency that moved, or a transport mismatch: "
          "{0}/mcp-health/diagnose/".format(base), "",
          "## Machine-readable", "",
          "- Every endpoint: {0}/mcp-health/feed.json".format(base),
          "- One server: {0}/mcp-health/server/<name>/".format(base),
          "- Badge: {0}/mcp-health/badge/<name>.svg".format(base),
          "- Browse every server A-Z: {0}/mcp-health/list/1/".format(base),
          "",
          "`<name>` is the registry name lower-cased with each run of "
          "punctuation collapsed to one hyphen: `io.github.Owner/My_Server` is "
          "`io-github-owner-my-server`.",
          "",
          "Method: one JSON-RPC `initialize` per endpoint per day, then "
          "`tools/list`. Both remote transports are tried — streamable HTTP "
          "(protocol {0}, one POST per message) and the original HTTP+SSE "
          "transport (2024-11-05: GET the stream, read the `event: endpoint` "
          "frame, POST there). Probing only the first transport published 143 "
          "working or gated servers as \"answers HTTP, not MCP\"; that was "
          "corrected on 2026-09-08. No score is computed and nothing is marked "
          "dead. "
          "Removal or questions: {1}".format(PROTOCOL_VERSION, CONTACT), "",
          "## Licence", "", LICENCE, ""]
    return "\n".join(L)


# --------------------------------------------------------------------------
# /mcp-health/diagnose/ -- the four failure causes, in prose.
#
# The CLI (tools/mcpdoctor.py) is run once by somebody who already has the
# problem. This page is the surface search and assistants can actually reach,
# which is why the causes are written out here in full rather than left in the
# tool's --help.
#
# Two rules this page keeps:
#
#   * Every verdict block below is VERBATIM output of tools/mcpdoctor.py,
#     captured 2026-09-15. It is not a mock-up of what the tool might say. If
#     the tool's wording changes, these change with it -- they are quoted from
#     the same NEXT_STEPS/HEADLINE tables the tool prints from, so a reader who
#     runs it gets the sentences this page promised.
#
#     Each is a real run against a small server written to fail that way, not
#     a run against somebody else's broken server. Two things are trimmed: the
#     two-line header echoing the command back, and the "Time to the verdict"
#     line, which is a different number every run.
#
#     That promise had nothing enforcing it, and on 2026-09-29 it turned out
#     to be false for one of the three: the transport block was missing the
#     "It speaks MCP ..." line and the whole "Lines it wrote to stdout ..."
#     section, both of which this cause always prints. Every line in it was
#     genuine, but it was an excerpt while the page said it was only
#     header-trimmed. Re-captured from a live run. `check_page_blocks()` in
#     ops/mcp-doctor-selftest.py now rebuilds all four from live runs and
#     diffs them against this list, so the next drift is a failing test rather
#     than a sentence nobody re-reads.
#
#     The fourth cause (2026-09-29) is the one exception, and it is labelled
#     as such ON THE PAGE rather than only here. It fires on native Windows
#     and nowhere else, and there is no Windows machine in this project. The
#     block is still a real run -- a real `bash`, really spawned, handed the
#     path out of `#1233`, really dying with exit 127 and that line on stderr
#     -- with exactly one fact supplied by hand: what `platform.system()`
#     answers, the only fact a Mac genuinely cannot supply. That leaves two
#     lines reading as a Mac prints them rather than as Windows would, and the
#     page names both. The editorial call to publish it that way, and the
#     alternative it was chosen over, are in state/decisions.md.
#   * The GitHub issue count is stated WITH ITS DATE and next to the query that
#     produced it, because it moves: the scout that found the pattern counted
#     130 on 2026-09-12 and the same search returned 129 on 2026-09-15. A bare
#     "130 issues" would be a live claim that quietly goes wrong. The query is
#     the durable part, so the query is what we publish.

# The evidence, as fetched. Each row: the issue, what it actually was, and
# which cause it demonstrates. The first five were verified 2026-09-15 against
# the GitHub API -- all five titles read back matching the descriptions below.
# The last two are the Windows pair, found by re-running the same search on
# 2026-09-28 and re-fetched in full on 2026-09-29, both pages read rather than
# taken from the earlier session's note: the quoted stderr line, the
# WindowsApps path, the exit 9009 aside and the auth-cache paragraph were each
# read off the page they are attributed to.
DIAGNOSE_EVIDENCE = [
    ("openclaw/openclaw#146268", "https://github.com/openclaw/openclaw/issues/146268",
     "a stdio server whose launcher binary (uvx) was not installed, failing "
     "silently as a generic \u201cConnection closed\u201d"),
    ("Atiqul-Islam/genesis#25", "https://github.com/Atiqul-Islam/genesis/issues/25",
     "a .mcp.json baking an unexpanded ${CLAUDE_PROJECT_DIR}, which the client "
     "never substitutes"),
    ("isac322/kwin-mcp#49", "https://github.com/isac322/kwin-mcp/issues/49",
     "mcp>=1.0.0 resolving to a 2.x that removed mcp.server.fastmcp, so the "
     "server dies at import"),
    ("sd2k/mcp-tokens#11", "https://github.com/sd2k/mcp-tokens/issues/11",
     "a server logging to stdout before its initialize response, corrupting "
     "the JSON-RPC stream"),
    ("espressif/esp-idf#19078", "https://github.com/espressif/esp-idf/issues/19078",
     "a chip vendor's own MCP server crashing on shutdown, which clients see "
     "as an immediate CONNECTION_CLOSED"),
    ("AxiomCodeAI/axiomcodegraph#1233",
     "https://github.com/AxiomCodeAI/axiomcodegraph/issues/1233",
     "a manifest launching its server with a bare bash, which on Windows "
     "resolves to the WindowsApps placeholder, forwards to WSL\u2019s shell "
     "and dies on a Windows-format path with exit 127"),
    ("aboul3ata/lazyweb-skill#156",
     "https://github.com/aboul3ata/lazyweb-skill/issues/156",
     "the same mistake in its other shape \u2014 command: \"sh\", which is "
     "not on the Windows PATH at all \u2014 and the client then misreading "
     "the dead process as an authentication failure"),
]

GH_QUERY = ('https://github.com/search?type=issues&q=%22mcp+server%22+'
            '%22connection+closed%22+in%3Atitle')
GH_COUNT, GH_COUNT_DATE = 129, "2026-09-15"

# Verbatim from tools/mcpdoctor.py, 2026-09-15. (headline, what-happened,
# [what-to-do...]) -- the same three parts the tool prints, in the same order.
DIAGNOSE_CAUSES = [
    {
        "id": "launcher",
        "h": "The launcher is not there",
        "lede": "The command in your config cannot start at all, because the "
                "program that runs it \u2014 <code>uvx</code>, <code>npx</code>, "
                "a virtualenv\u2019s <code>python</code> \u2014 is not on the "
                "PATH the client is using, or a <code>${VAR}</code> in the "
                "command was never expanded. Nothing runs, so nothing answers, "
                "so the client says the connection closed.",
        "says": "NOT WORKING: the command cannot start, because the launcher "
                "is not there.\n\n"
                "What happened: `uvx` is not on PATH, so the command cannot "
                "start at all.\n\n"
                "What to do\n"
                "  * Install the launcher, or give the config an absolute path "
                "to it.\n"
                "  * Client configs do not inherit your shell's PATH. A command "
                "that works in your terminal can still be missing here -- "
                "`which uvx` in the terminal, then put that full path in the "
                "config.",
        "trap": "This is the one that wastes the most time, because the "
                "command genuinely does work when you paste it into a "
                "terminal. A desktop client launched from the Dock or a "
                "service manager does not inherit the PATH your shell builds "
                "in <code>.zshrc</code>. The fix is almost always an absolute "
                "path in the config rather than a bare command name.",
    },
    {
        "id": "dependency",
        "h": "A dependency moved under it",
        "lede": "The server starts, throws on import and exits before it can "
                "answer. Nothing you wrote changed: a version range like "
                "<code>mcp&gt;=1.0.0</code> picked up a new major release, and "
                "<code>uvx</code> and <code>npx</code> resolve dependencies "
                "afresh on every run, so the break arrives on a machine that "
                "worked yesterday. The traceback goes to stderr \u2014 which "
                "the client throws away.",
        "says": "NOT WORKING: it starts and dies, because a dependency broke "
                "under it.\n\n"
                "What happened: a Python import failed on a NAME, which is what "
                "a dependency bumping a major version looks like: the module is "
                "there, the symbol moved.\n"
                "Read off its own output:\n"
                "    ImportError: cannot import name \"Server\" from "
                "\"mcp.server\"\n\n"
                "Its stderr (the part your client discarded), last 4 lines:\n"
                "    Traceback (most recent call last):\n"
                "      File \"srv.py\", line 3, in <module>\n"
                "        from mcp.server import Server\n"
                "    ImportError: cannot import name \"Server\" from "
                "\"mcp.server\"\n\n"
                "What to do\n"
                "  * Pin the dependency. A range like `mcp>=1.0.0` picks up the "
                "next major release on the next clean install, and `uvx`/`npx` "
                "do a clean install every time -- which is why this breaks with "
                "no change of yours.\n"
                "  * Re-run with the version pinned (`uvx pkg==<last good>`) to "
                "confirm before you change the pin for good.",
        "trap": "The useful half is the last block: the server\u2019s own "
                "stderr. Your client captured it and discarded it, which is "
                "the entire reason this presents as a blank \u201cconnection "
                "closed\u201d rather than as the <code>ImportError</code> it "
                "actually is.",
    },
    {
        "id": "transport",
        "h": "The transport does not match",
        "lede": "Something is running and healthy, and the two sides are not "
                "speaking the same way. Remotely, MCP has two transports \u2014 "
                "streamable HTTP (one POST per message) and the original "
                "HTTP+SSE (GET the stream, read the <code>event: endpoint</code> "
                "frame, POST there) \u2014 and a client aimed at the wrong one "
                "gets nothing back however healthy the server is. Locally, the "
                "equivalent is a server that prints a log line to stdout: on "
                "stdio, stdout <em>is</em> the wire, and one stray line "
                "corrupts the stream.",
        "says": "IT ANSWERED US, AND IT WILL STILL FAIL IN A STRICT CLIENT: it "
                "writes lines to stdout that are not JSON-RPC.\n\n"
                "What happened: initialize ok; tools/list returned 1 tool; but "
                "it also wrote 1 non-JSON line to stdout, which a strict client "
                "treats as a broken stream.\n"
                "Read off its own output:\n"
                "    [info] starting server, loading config from ~/.config\n"
                "It speaks MCP 2025-06-18, server x.\n\n"
                "Lines it wrote to stdout that are not JSON-RPC (these break "
                "the protocol):\n"
                "    [info] starting server, loading config from ~/.config\n\n"
                "What to do\n"
                "  * Move that output to stderr. On stdio, stdout carries the "
                "JSON-RPC messages and nothing else -- one stray print closes "
                "the connection.\n"
                "  * In Python that is `print(..., file=sys.stderr)` or a "
                "logging handler on stderr; in Node, `console.error` rather "
                "than `console.log`.",
        "trap": "Note what that verdict does not say: it does not say "
                "\u201chealthy\u201d. Our reader skips a line it cannot parse, "
                "so the server works <em>for us</em> \u2014 and a strict client "
                "closes the connection on it, which is what the person "
                "reporting the bug sees. Saying \u201cworking\u201d there would "
                "have been the friendlier answer and the wrong one. This is "
                "also the class our own prober got wrong until 2026-09-08, when "
                "it published 143 servers as \u201canswers HTTP, not "
                "MCP\u201d that were nothing of the kind.",
    },
    {
        "id": "windows-shell",
        "h": "The launcher is a POSIX shell name, and the machine is Windows",
        "lede": "This one only happens on native Windows, which is precisely "
                "why it earns a heading: a config naming a bare "
                "<code>bash</code> or <code>sh</code> as the "
                "<code>command</code> works on every machine its author "
                "tried, and on Windows that name is resolved off the "
                "<em>Windows</em> PATH \u2014 which reaches WSL\u2019s shell, "
                "or nothing at all, but never Git Bash. <code>sh</code> is "
                "simply absent: Git for Windows ships "
                "<code>C:\\Program Files\\Git\\bin\\sh.exe</code> and does "
                "not put that directory on PATH, so nothing starts. "
                "<code>bash</code> is worse, because it <em>does</em> resolve "
                "\u2014 to "
                "<code>%LOCALAPPDATA%\\Microsoft\\WindowsApps\\bash.exe</code>, "
                "the placeholder that forwards to WSL \u2014 so a process "
                "really starts, is handed a <code>C:/\u2026</code> path WSL "
                "cannot see, and exits 127.",
        "says": "NOT WORKING: the config starts it with a bare `bash`/`sh`, "
                "which on Windows is not the shell it needs.\n\n"
                "What happened: `bash` resolved to /bin/bash, and that shell "
                "could not find the script it was handed: exit 127.\n"
                "Read off its own output:\n"
                "    bash: C:/Users/<user>/.claude/plugins/cache/axiomcode/"
                "axiomcode/0.1.0/mcp/launch.sh: No such file or directory\n\n"
                "Its stderr (the part your client discarded), last 1 lines:\n"
                "    bash: C:/Users/<user>/.claude/plugins/cache/axiomcode/"
                "axiomcode/0.1.0/mcp/launch.sh: No such file or directory\n\n"
                "What to do\n"
                "  * Do not name a bare `bash` or `sh` as the command on "
                "Windows. The client spawns it from the Windows PATH, not "
                "from Git Bash, so it reaches WSL's shell or nothing -- never "
                "the shell your script expects.\n"
                "  * The absolute path to Git's shell (`C:\\Program Files\\"
                "Git\\bin\\sh.exe`) works and is worth trying now to confirm "
                "the diagnosis, but it is lost on the next install or plugin "
                "update.\n"
                "  * The durable fix is a launcher that exists under the same "
                "name on every platform, with the shell logic moved into it "
                "-- `node` and a small script, typically. Note that `python3` "
                "has the identical trap: on Windows it is a Microsoft Store "
                "placeholder that exits 9009, while `python` works there and "
                "is missing on machines that only have `python3`. No single "
                "interpreter name is portable.\n"
                "  * If your client has started asking you to re-authenticate "
                "this server, that is a separate bug and not worth following. "
                "A stdio server whose process dies can be misread as an auth "
                "failure and cached as one (Claude Code writes it into "
                "`mcp-needs-auth-cache.json`); no amount of re-authenticating "
                "fixes a launcher that never ran.",
        # Every other block on this page is a real run on the platform it
        # describes. This one cannot be, so it says so in place rather than
        # letting the standard sentence make a claim we cannot support.
        "says_note": "<strong>What the tool prints \u2014 a real run, with "
                     "one fact supplied.</strong> This fault happens on "
                     "native Windows and nowhere else, and there is no "
                     "Windows machine here, so unlike the three blocks above "
                     "this one was not produced on the platform it describes. "
                     "It is still a real run of the real tool: a real "
                     "<code>bash</code>, really spawned, handed the path out "
                     "of <code>#1233</code>, really exiting 127 with that "
                     "line on its stderr. One fact is supplied by hand \u2014 "
                     "what <code>platform.system()</code> answers \u2014 "
                     "because it is the only fact a Mac cannot give. Two "
                     "lines therefore read as a Mac prints them: the shell "
                     "resolves to <code>/bin/bash</code> where Windows shows "
                     "<code>%LOCALAPPDATA%\\Microsoft\\WindowsApps\\bash.exe"
                     "</code> and adds that it is the WSL placeholder, and "
                     "Windows prefixes the stderr line "
                     "<code>/bin/bash:</code> rather than "
                     "<code>bash:</code>. Everything else is what a Windows "
                     "user sees, and if you are on Windows you can run it and "
                     "get the real thing:",
        "says_note_md": "What the tool prints -- a real run, with one fact "
                        "supplied. This fault happens on native Windows and "
                        "nowhere else, and there is no Windows machine here, "
                        "so unlike the three blocks above this one was not "
                        "produced on the platform it describes. It is still a "
                        "real run of the real tool: a real `bash`, really "
                        "spawned, handed the path out of `#1233`, really "
                        "exiting 127 with that line on its stderr. One fact "
                        "is supplied by hand -- what `platform.system()` "
                        "answers -- because it is the only fact a Mac cannot "
                        "give. Two lines therefore read as a Mac prints them: "
                        "the shell resolves to `/bin/bash` where Windows "
                        "shows `%LOCALAPPDATA%\\Microsoft\\WindowsApps\\"
                        "bash.exe` and adds that it is the WSL placeholder, "
                        "and Windows prefixes the stderr line `/bin/bash:` "
                        "rather than `bash:`. Everything else is what a "
                        "Windows user sees.",
        "trap": "The trap is that every ordinary check passes. The script "
                "exists, the config is valid JSON, and "
                "<code>shutil.which(\u201cbash\u201d)</code> \u2014 which is "
                "what a launcher-missing check does, including ours \u2014 "
                "succeeds, because the WindowsApps alias really is on the "
                "PATH. So until 2026-09-29 this was diagnosed as nothing at "
                "all: it fell through to \u201cthe process exited with code "
                "127\u201d with no cause named, which is the one outcome this "
                "tool is otherwise built to avoid. It also comes with a "
                "decoy. A stdio server whose process dies can be misread by "
                "the client as an authentication failure and cached as one, "
                "sending you round a re-authentication loop that cannot fix "
                "anything \u2014 <code>#156</code> documents exactly that, "
                "alongside its own evidence that a direct request with the "
                "stored token returned HTTP 200 and all 41 tools. The "
                "credentials were never the problem. Nor is the one-word swap "
                "most people try next: <code>python3</code> has the same trap "
                "on Windows, where it is a Store placeholder that exits 9009.",
    },
]


# One complete run, captured 2026-09-15 from a copy of the two files outside
# the repo with no state/ directory -- which is the case a reader downloading
# them from this page is in. Nothing is elided: the timing line varies run to
# run and is left in rather than tidied away, because the block is offered as
# a real run and a tidied one is not that.
REMOTE_TRANSCRIPT = """\
$ python3 mcpdoctor.py https://api.inference.sh/mcp

  https://api.inference.sh/mcp
  ----------------------------

  WORKING, BUT IT WANTS CREDENTIALS.

  What happened: HTTP 401.
  Transport that answered: streamable HTTP; HTTP 401
  Time to the verdict: 765 ms.

  What to do
    * This server works; it wants credentials. Not a fault.
    * Add the header or token its documentation asks for.

  We have been probing this exact address daily, last run 2026-09-15.
    8 checks; up 14.3% over 7 days, 25.0% over 30.
    Last seen answering with a tool list: 2026-09-09.
    Oldest first: 2 days answered, tools listed, then 6 days needs credentials.
    https://referencesource.org/mcp-health/

  For scale: 4,725 of the 19,394 registry endpoints we probed on 2026-09-15 \
landed in this same bucket (24.4%) -- https://referencesource.org/mcp-health/"""


def diagnose_html(data, site):
    import render as R

    n = data["endpoints"]
    c = data["counts"]
    latest = data["latest_date"]
    base = site["base_url"].rstrip("/")
    h = []
    h.append('<h1>Why does my MCP server say &ldquo;connection '
             'closed&rdquo;?</h1>')
    h.append('<p class="hero">Almost always one of four things: the launcher '
             'is missing, a dependency moved under it, the transport does '
             'not match, or \u2014 on Windows only \u2014 the config names a '
             'bare <code>bash</code> or <code>sh</code>, which Windows '
             'resolves to something that is not the shell you meant. Each one '
             'is identifiable in a single command, and the client\u2019s own '
             'error message tells you which it is in none of the four cases. '
             'Below: what each looks like, the command that names it, and '
             'what the fix is.</p>')

    h.append('<p class="prose">This is the same check we run against every '
             'endpoint on the official MCP registry, once a day \u2014 '
             '{0:,} of them, last on {1} \u2014 pointed at one server instead '
             'of all of them, running on your machine. The daily results are '
             'at <a href="/mcp-health/">Is that MCP server actually '
             'up?</a>.</p>'.format(n, R.time_html(latest) or latest))

    # --- the command, high on the page. Somebody arriving here from a search
    # for the error string wants to run something, not read our reasoning.
    h.append("<h2>Run it</h2>")
    h.append('<p class="prose">Two files, no install, standard library only. '
             'They are short enough to read before you run them, which is the '
             'recommended order.</p>')
    h.append('<pre class="snip"><code>'
             'curl -O {0}/mcp-health/diagnose/mcpdoctor.py\n'
             'curl -O {0}/mcp-health/diagnose/mcphealth.py\n\n'
             '# a remote server\n'
             'python3 mcpdoctor.py https://example.com/mcp\n\n'
             '# a local one: everything after -- is the command your config runs\n'
             'python3 mcpdoctor.py -- uvx mcp-server-time\n\n'
             '# or read the config itself and check every server in it\n'
             'python3 mcpdoctor.py --config .mcp.json --all'
             '</code></pre>'.format(_esc(base)))
    h.append('<p class="prose">It sends no credentials, writes nothing outside '
             'a cache directory, and needs no account. '
             '<a href="/mcp-health/diagnose/mcpdoctor.py">mcpdoctor.py</a> is '
             'the diagnosis; '
             '<a href="/mcp-health/diagnose/mcphealth.py">mcphealth.py</a> is '
             'the prober it shares with the daily run, so the remote verdicts '
             'here and the verdicts on the scoreboard cannot drift apart.</p>')

    # --- the four causes
    h.append("<h2>The four causes</h2>")
    h.append('<p class="prose">These are not a guess at what usually goes '
             'wrong. A search of GitHub for issues with both \u201cMCP '
             'server\u201d and \u201cconnection closed\u201d <em>in the title '
             'alone</em> returned {0} of them on {1} \u2014 across entirely '
             'unrelated projects: a chip vendor\u2019s SDK, an editor, a '
             'browser agent, a desktop client, coding agents. Fifteen were read '
             'in full. The first three causes below are drawn from that '
             'sample, and <strong>not one of the fifteen reporters had worked '
             'out which cause they had</strong> \u2014 each thread is \u201cit '
             'just says connection closed\u201d followed by somebody else doing '
             'the archaeology in the comments. '
             '<a href="{2}" rel="nofollow">Re-run the search</a>; the count '
             'moves, the pattern does not.</p>'.format(
                 GH_COUNT, GH_COUNT_DATE, _esc(GH_QUERY)))
    h.append('<p class="prose">The fourth was added on 2026-09-29, and how it '
             'arrived is worth saying, because it is the test of whether this '
             'page is a list or a live reading. Re-running the same search '
             'over 2026-09-12 to 2026-09-28 returned eleven issues; two of '
             'them, opened a day apart in unrelated projects, turned out to '
             'describe the same Windows-only mistake, and our own tool named '
             'neither. Both were read in full and the cause was added to the '
             'tool before it was added here.</p>')

    for cause in DIAGNOSE_CAUSES:
        h.append('<h3 id="{0}">{1}</h3>'.format(
            _esc(cause["id"]), _esc(cause["h"])))
        h.append('<p class="prose">{0}</p>'.format(cause["lede"]))
        # "Verbatim" has to mean verbatim on this site. These blocks are the
        # tool's real output; the only things removed are the two-line header
        # echoing the command and the timing line, which is a different number
        # every run. A cause that cannot be run on the platform it describes
        # says so itself, in `says_note`, rather than taking this sentence.
        h.append('<p class="prose">{0}</p>'.format(
            cause.get("says_note")
            or ('<strong>What the tool prints.</strong> Real output, not a '
                'mock-up \u2014 only the header echoing the command back and '
                'the timing line, which differs every run, are trimmed:')))
        h.append('<pre class="snip"><code>{0}</code></pre>'.format(
            _esc(cause["says"])))
        h.append('<p class="prose">{0}</p>'.format(cause["trap"]))

    # --- what only we have: the series
    h.append("<h2>For a remote server, it also knows when it started</h2>")
    h.append('<p class="prose">There are other MCP inspectors. What none of '
             'them has is a history. If the address you are diagnosing is one '
             'of the {0:,} we probe, the answer carries our own daily series '
             'for it \u2014 so you can tell \u201cit is broken now\u201d from '
             '\u201cit started asking for a key on the 9th\u201d, which are '
             'different problems with different fixes. This is one complete '
             'run, nothing removed:</p>'.format(n))
    h.append('<pre class="snip"><code>{0}</code></pre>'.format(
        _esc(REMOTE_TRANSCRIPT)))
    h.append('<p class="prose">That history travels with the tool. Inside our '
             'own repository it is read from the day files; anywhere else it '
             'comes from <a href="/mcp-health/feed.json">the published '
             'feed</a>, fetched once into a cache directory, reused for a day, '
             'and skipped <em>in silence</em> if the fetch fails \u2014 because '
             'a diagnostic tool that prints a network error of its own while '
             'you are diagnosing a network problem is worse than one that says '
             'nothing.</p>')

    # --- limits. Stated on the page, not only in the tool.
    h.append("<h2>What this cannot tell you</h2>")
    h.append('<p class="prose"><strong>A cause is never asserted without the '
             'line it was read off.</strong> If nothing in the server\u2019s '
             'stderr matches a signature it knows, it says the process exited '
             'with code N and prints the tail verbatim. It does not guess, and '
             'a confident wrong diagnosis would be worse than none.</p>')
    h.append('<p class="prose"><strong>The comparison figures are for remote '
             'servers only.</strong> Our {0:,} endpoints are addresses on the '
             'public registry. A local stdio server is not in that population, '
             'so no \u201cthis many others are like you\u201d percentage is '
             'printed for one \u2014 it would invite a comparison the '
             'denominator does not support.</p>'.format(n))
    h.append('<p class="prose"><strong>It diagnoses starting and connecting, '
             'not behaving.</strong> Whether a tool returns the right answer, '
             'whether its schema is sane, whether it is safe to give it '
             'credentials \u2014 none of that is in scope here.</p>')

    broken = c.get("not_mcp", 0) + c.get("no_tools", 0)
    h.append('<p class="prose"><strong>It is a common problem, not your '
             'mistake.</strong> Of the {0:,} registry endpoints probed on {1}, '
             '{2:,} ({3:.1f}%) answered HTTP and could not complete an MCP '
             'session. A reachability check \u2014 DNS, a ping, an HTTP 200 '
             '\u2014 marks every one of them healthy. Only a real handshake '
             'sees it.</p>'.format(n, latest, broken, _pct(broken, n)))

    h.append("<h2>The issues this was built from</h2>")
    h.append('<p class="prose">The issues the causes were read off, so the '
             'reasoning can be checked rather than taken. The first five are '
             'the 2026-09-15 sample behind the first three causes; the last '
             'two are the Windows pair behind the fourth:</p>')
    h.append("<ul class=\"prose\">")
    for ref, url, what in DIAGNOSE_EVIDENCE:
        h.append('<li><a href="{0}" rel="nofollow"><code>{1}</code></a> '
                 '\u2014 {2}</li>'.format(_esc(url), _esc(ref), _esc(what)))
    h.append("</ul>")

    # Two different things are published on this page -- the measurements, and
    # two source files -- and one licence block covering both by implication
    # would be a claim we have not actually made. The data licence is scoped in
    # its own sentence; the code licence is rendered only once site.json says
    # what it is, because granting one is irreversible and is Joe's call.
    # Queued as `mcp-doctor-code-licence`; render_site warns while it is unset.
    h.append('<p class="meta" id="licence"><strong>Licence, for the '
             'measurements.</strong> {0}</p>'.format(_esc(LICENCE)))
    code_lic = site.get("code_licence")
    if code_lic:
        h.append('<p class="meta"><strong>Licence, for the two Python files '
                 'above.</strong> {0}</p>'.format(_esc(code_lic)))
    h.append('<p class="meta">Questions, corrections, or to have your server '
             'left out of the daily probe: <a href="mailto:{0}">{0}</a>.</p>'
             .format(CONTACT))
    return "\n".join(h)


def diagnose_md(data, site):
    """The markdown twin at /mcp-health/diagnose/index.md."""
    n = data["endpoints"]
    c = data["counts"]
    base = site["base_url"].rstrip("/")
    broken = c.get("not_mcp", 0) + c.get("no_tools", 0)

    def plain(s):
        # The prose above carries a little inline HTML for emphasis; the
        # markdown twin is the same sentences without it.
        return re.sub(r"<[^>]+>", "", s)

    L = ["# Why does my MCP server say \u201cconnection closed\u201d?", "",
         "Almost always one of four things: the launcher is missing, a "
         "dependency moved under it, the transport does not match, or -- on "
         "Windows only -- the config names a bare `bash`/`sh`, which Windows "
         "resolves to something that is not the shell you meant. Each is "
         "identifiable in one command, and the client's own error message "
         "distinguishes none of them.", "",
         "This is the check we run against every endpoint on the official MCP "
         "registry once a day ({0:,} of them, last on {1}), pointed at one "
         "server instead of all of them. Daily results: "
         "{2}/mcp-health/".format(n, data["latest_date"], base), "",
         "## Run it", "",
         "Two files, no install, standard library only.", "",
         "```",
         "curl -O {0}/mcp-health/diagnose/mcpdoctor.py".format(base),
         "curl -O {0}/mcp-health/diagnose/mcphealth.py".format(base),
         "",
         "python3 mcpdoctor.py https://example.com/mcp      # a remote server",
         "python3 mcpdoctor.py -- uvx mcp-server-time       # a local one",
         "python3 mcpdoctor.py --config .mcp.json --all     # every server in a config",
         "```", "",
         "It sends no credentials and needs no account.", "",
         "## The four causes", "",
         "Not a guess at what usually goes wrong. A GitHub search for issues "
         "with both \u201cMCP server\u201d and \u201cconnection closed\u201d in "
         "the TITLE alone returned {0} on {1}, across unrelated projects. "
         "Fifteen were read in full; not one reporter had worked out which "
         "cause they had. The first three causes come from that sample. Re-run "
         "it: {2} (the count moves, the pattern does "
         "not).".format(GH_COUNT, GH_COUNT_DATE, GH_QUERY), "",
         "The fourth was added on 2026-09-29. Re-running the same search over "
         "2026-09-12 to 2026-09-28 returned eleven issues; two of them, "
         "opened a day apart in unrelated projects, described the same "
         "Windows-only mistake, and our own tool named neither. Both were "
         "read in full and the cause was added to the tool before it was "
         "added here.", ""]

    for cause in DIAGNOSE_CAUSES:
        L += ["### {0}".format(cause["h"]), "", plain(cause["lede"]), "",
              cause.get("says_note_md")
              or ("What the tool prints -- real output, only the header "
                  "echoing the command and the timing line, which differs "
                  "every run, trimmed:"), "",
              "```", cause["says"], "```", "", plain(cause["trap"]), ""]

    L += ["## For a remote server, it also knows when it started", "",
          "Other MCP inspectors exist; none has a history. If the address is "
          "one of the {0:,} we probe, the answer carries our daily series for "
          "it, so \u201cit is broken now\u201d and \u201cit started asking for "
          "a key on the 9th\u201d are distinguishable. One complete run, "
          "nothing removed:".format(n), "",
          "```", REMOTE_TRANSCRIPT, "```", "",
          "Outside our repository that history comes from "
          "{0}/mcp-health/feed.json, cached for a day and skipped in silence if "
          "the fetch fails.".format(base), "",
          "## What this cannot tell you", "",
          "- A cause is never asserted without the line it was read off. With "
          "no matching signature it prints the exit code and the stderr tail "
          "verbatim rather than guessing.",
          "- Comparison figures are for remote servers only: our {0:,} "
          "endpoints are registry addresses, and a local stdio server is not in "
          "that population.".format(n),
          "- It diagnoses starting and connecting, not behaving. Tool "
          "correctness, schema quality and whether a server is safe to "
          "credential are all out of scope.",
          "- Of {0:,} registry endpoints probed on {1}, {2:,} ({3:.1f}%) "
          "answered HTTP and could not complete an MCP session. A reachability "
          "check marks all of them healthy.".format(
              n, data["latest_date"], broken, _pct(broken, n)), "",
          "## The issues this was built from", ""]
    for ref, url, what in DIAGNOSE_EVIDENCE:
        L.append("- {0} \u2014 {1} ({2})".format(ref, what, url))
    L += ["", "## Licence, for the measurements", "", LICENCE, ""]
    if site.get("code_licence"):
        L += ["## Licence, for the two Python files", "",
              site["code_licence"], ""]
    L += ["Questions or opt-out: {0}".format(CONTACT), ""]
    return "\n".join(L)


# The badge SVG is generated by functions/mcp-health/badge/[name].js, not
# here. A second implementation in Python would be a second place for the
# wording and the colours to drift from the page the badge links to, and a
# badge that disagreed with its own page is worse than no badge.


# --------------------------------------------------------------------------
# The sitemap seed cohort
#
# Measured 2026-09-16 with the Search Console URL Inspection API
# (candidates/2026-09-16-mcp-health-index-probe-result.md): Google has
# discovered 16 of 63 depth-1 namespace index pages on this site and **0 of 62**
# `/<ns>/list/<n>/` pagination hubs (Fisher one-sided p = 5.9e-06). Both shapes
# are admitted to the focused sitemap unconditionally, so neither cohort is
# selection-biased, and the difference is real.
#
# That is the whole story for /mcp-health/. Its 78 sitemap URLs are 76 hubs, one
# index page and /diagnose/; all 78 come back "URL is unknown to Google". The
# ~18,900 per-server pages are NOT held back by render.py's focus filter -- they
# are served by a Cloudflare Function out of the shards below and have never
# been candidates for the sitemap at all, because `render_site` returns only the
# index and the hubs. So `sitemap_always` in site.json, which is a whitelist
# applied to URLs render.py has already produced, cannot reach them. Nothing
# short of putting the URLs in this function's return value can.
#
# This seeds a frozen slice of them, so the question can be answered: does a
# record page in a namespace with no other entrance get discovered when the
# sitemap names it? The cohort is FROZEN to a file rather than recomputed each
# render because the registry churns daily, and a cohort that drifts is not a
# cohort. Write it once with `--seed-sitemap N`; `render_site` only reads it.
#
# Two things the reader of the result has to know:
#   * The control is the 76 `/mcp-health/list/<n>/` hubs -- same namespace, same
#     sitemap, same day, measured 0 of 62 discovered before the seed went in.
#     `state/mcp-health/sitemap-seed.json` also freezes an equal-sized set of
#     server pages that are NOT seeded, which is the better control, but gsc.py
#     can only inspect URLs that are in the sitemap, so it is unusable until
#     that changes.
#   * These pages really do change every day (the uptime figures move), so they
#     will carry `lastmod: <today>` every day -- the churn pattern Bing warns
#     about. The 2026-09-16 run refuted daily-lastmod as the cause of the HUB
#     blackout; it is not refuted here. If this comes back 0 of N, that is the
#     next suspect, not a settled verdict on sitemaps.

SITEMAP_SEED = STATE / "sitemap-seed.json"


def _seed_rank(slug):
    """A stable, opaque order over slugs, so that nobody picked the cohort.

    Ranking on anything in the data -- uptime, tool count, name -- would
    confound "we put it in the sitemap" with "it was a better page to begin
    with". This is a hash of the slug and nothing else.
    """
    return hashlib.sha1(
        ("mcp-health-sitemap-seed/" + slug).encode("utf-8")).hexdigest()


def load_sitemap_seed():
    """The frozen cohort, or None. Never writes -- a render has to stay pure."""
    try:
        d = json.loads(SITEMAP_SEED.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return None
    return d if isinstance(d, dict) and d.get("seeded") else None


def seed_sitemap_cohorts(n=120, data=None):
    """Freeze `n` server slugs into the sitemap and `n` more as a held-back set.

    Eligible slugs are the ones seen on every probe day so far. A slug that
    drops off the registry stops resolving, and a 404 inside the cohort would
    read as "Google would not index it" when the truth is there was nothing
    there to index. That filter selects for registry persistence, not for page
    quality, and it is applied identically to both cohorts, so it cannot bias
    the comparison.

    The two cohorts are interleaved down the hash order rather than split at its
    midpoint, so they are drawn from the same stretch of it.
    """
    data = data or load_latest()
    if not data:
        print("mcp-health: no probe days yet — nothing to seed")
        return 1
    slugs, _shared = unique_slugs(data["servers"])
    days = data["days_of_history"]
    eligible = sorted({slugs[s["name"]] for s in data["servers"]
                       if s.get("checks") == days})
    if len(eligible) < 2 * n:
        print("mcp-health: only {0:,} slugs are present on all {1} probe "
              "days; two cohorts of {2} need {3:,}".format(
                  len(eligible), days, n, 2 * n))
        return 1
    eligible.sort(key=_seed_rank)
    picked = eligible[:2 * n]
    doc = {
        "created": dt.date.today().isoformat(),
        "experiment": "sitemap-hub-shape-discovery",
        "n": n,
        "eligible": len(eligible),
        "days_of_history": days,
        "endpoints_at_seed": data["endpoints"],
        "note": "Frozen cohort for the sitemap-shape experiment. `seeded` goes "
                "into the sitemap from the next render; `held_back` is an "
                "equal-sized set drawn the same way that does not, and is the "
                "control the moment anything here can inspect a URL that is "
                "not in the sitemap. Until then the control is the 76 "
                "/mcp-health/list/<n>/ hubs, measured 0 of 62 discovered on "
                "2026-09-16. DO NOT REGENERATE: a cohort that moves between "
                "runs measures nothing. Eligibility was 'present on every "
                "probe day', so a slug here can still leave the registry — "
                "render_site drops those and reports the attrition.",
        "baseline_2026_09_16": {
            "mcp_health_sitemap_urls": 78,
            "mcp_health_discovered": 0,
            "hubs_site_wide": "0 of 62 discovered",
            "namespace_indexes_site_wide": "16 of 63 discovered",
        },
        "read_it_with": "python3 tools/gsc.py --inspect /mcp-health/server/ "
                        "--control 76 --control-match "
                        "'^/mcp-health/list/[0-9]+/$'",
        "seeded": picked[0::2],
        "held_back": picked[1::2],
    }
    # The same cohort as site-relative paths, which is the form render.py's
    # focus filter matches on. Written here rather than derived there so the
    # filter never has to know what an MCP server is, and so one file is the
    # whole experiment. `sitemap_seed_files` in site.json points at it.
    doc["paths"] = ["mcp-health/server/" + s for s in doc["seeded"]]
    SITEMAP_SEED.parent.mkdir(parents=True, exist_ok=True)
    SITEMAP_SEED.write_text(json.dumps(doc, indent=1), encoding="utf-8")
    print("mcp-health: froze {0} seeded and {1} held-back server slugs out of "
          "{2:,} eligible -> {3}".format(
              len(doc["seeded"]), len(doc["held_back"]), len(eligible),
              SITEMAP_SEED))
    return 0


def render_site(out, site=None, data=None, verbose=True):
    """Write /mcp-health/ and return [(url, lastmod)] for the sitemap."""
    import render as R

    data = data or load_latest()
    if not data:
        if verbose:
            print("  mcp-health: no probe days yet — nothing rendered")
        return []
    site = site or R.load_site()
    base = site["base_url"].rstrip("/")
    d = out / "mcp-health"
    d.mkdir(parents=True, exist_ok=True)
    slugs, shared = unique_slugs(data["servers"])

    ld = {"@context": "https://schema.org", "@type": "Dataset",
          "name": "MCP server reliability registry",
          "description": "Daily liveness probe of every server on the official "
                         "Model Context Protocol registry that publishes a "
                         "connect URL.",
          "url": base + "/mcp-health/",
          "dateModified": data["latest_date"],
          # Both, because they answer different questions. `license` is the URL
          # of the statement, which is the form every asset page emits and the
          # one a catalogue dereferences; `usageInfo` carries the words, so a
          # reader of the JSON-LD alone is not sent on a second fetch.
          "license": base + "/mcp-health/#licence",
          "usageInfo": LICENCE,
          "distribution": [{"@type": "DataDownload",
                            "encodingFormat": "application/json",
                            "contentUrl": base + "/mcp-health/feed.json"}],
          "creator": {"@type": "Organization", "name": site.get("name"),
                      "url": base + "/"}}
    (d / "index.html").write_text(R.page(
        "Is that MCP server actually up? — " + str(site.get("name") or ""),
        scoreboard_html(data, site), site, ld,
        "Every server on the official MCP registry that publishes a connect "
        "URL, probed daily with a real MCP handshake. Uptime, latency, tool "
        "counts and a free JSON feed.",
        "/mcp-health/", [("Home", "/"), ("MCP server health", None)]),
        encoding="utf-8")
    (d / "index.md").write_text(scoreboard_md(data, site), encoding="utf-8")

    feed = {
        "name": "MCP server reliability registry",
        "url": base + "/mcp-health/",
        "generated": data["generated"],
        "latest_date": data["latest_date"],
        "days_of_history": data["days_of_history"],
        "method": "One JSON-RPC initialize per endpoint per day, then "
                  "tools/list. Both remote transports: streamable HTTP "
                  "(protocol " + PROTOCOL_VERSION + ", one POST per message) "
                  "and the original HTTP+SSE transport (2024-11-05: GET the "
                  "stream, read the event: endpoint frame, POST there). "
                  "`via` says which one answered.",
        "classes": CLASS_LABEL,
        "licence": LICENCE,
        "licence_url": base + "/mcp-health/#licence",
        "contact": CONTACT,
        "endpoints": data["endpoints"],
        "counts": data["counts"],
        "no_auth": data.get("no_auth"),
        # Absent until a second fingerprinted probe exists; see drift_summary.
        "drift": data.get("drift"),
        "drift_method": "On every probe that returns a tool list we hash each "
                        "tool's name, description and input schema, and diff "
                        "that map against the last one we held for the same "
                        "endpoint. `schema_sig` is a digest of the whole "
                        "inventory; `drift` names what moved and the date it "
                        "moved from; `drift_days` counts the days in the "
                        "window on which it moved. We store digests only — no "
                        "tool name, description or schema is kept or "
                        "republished.",
        "runs": data["runs"],
        "servers": [{k: s.get(k) for k in
                     ("name", "url", "transport", "via", "class", "ms",
                      "tools", "protocol", "up7", "up30", "p50_ms", "p95_ms",
                      "last_healthy", "checks", "strip",
                      "schema_sig", "schema_days", "drift", "drift_days")}
                    for s in data["servers"]],
    }
    (d / "feed.json").write_text(json.dumps(feed), encoding="utf-8")

    # A-Z browse pages. Cloudflare Pages counts files and site/ is at ~94,500
    # of 100,000, so one static page per server was never available -- but 63
    # index pages of 250 are, and they are what gives every per-server page an
    # internal link and a crawlable path. /list/<n>/ is deliberately the same
    # URL shape render.py uses for a capped dataset index, because the sitemap's
    # focus filter recognises that shape as a hub and keeps it in the feed.
    browse = browse_groups(data, slugs)
    pages = (len(browse) + BROWSE_PER_PAGE - 1) // BROWSE_PER_PAGE
    list_urls = []
    for k in range(1, pages + 1):
        chunk = browse[(k - 1) * BROWSE_PER_PAGE:k * BROWSE_PER_PAGE]
        ld = d / "list" / str(k)
        ld.mkdir(parents=True, exist_ok=True)
        (ld / "index.html").write_text(R.page(
            "MCP servers {0}–{1} of {2:,} — page {3} — {4}".format(
                (k - 1) * BROWSE_PER_PAGE + 1,
                (k - 1) * BROWSE_PER_PAGE + len(chunk), len(browse), k,
                str(site.get("name") or "")),
            browse_pages_html(chunk, k, pages, len(browse), data), site, None,
            "Page {0} of every server on the official MCP registry that "
            "publishes a connect URL, probed daily: {1} to {2}.".format(
                k, chunk[0]["name"], chunk[-1]["name"]),
            "/mcp-health/list/{0}/".format(k),
            [("Home", "/"), ("MCP server health", "/mcp-health/"),
             ("Page {0}".format(k), None)]), encoding="utf-8")
        list_urls.append((base + "/mcp-health/list/{0}/".format(k),
                          data["latest_date"]))

    # Shards for the Function. Keyed by slug so the Worker needs no index.
    # One slug holds every endpoint of one server; a slug two different servers
    # would otherwise share holds a pointer to each of them instead.
    sd = d / "s"
    sd.mkdir(parents=True, exist_ok=True)
    buckets = {}
    for s in data["servers"]:
        sl = slugs[s["name"]]
        buckets.setdefault(shard_of(sl), {}).setdefault(sl, []).append(s)
    for bare, entries in sorted(shared.items()):
        b = buckets.setdefault(shard_of(bare), {})
        if bare in b:
            # A real server is already at that slug, so the disambiguation
            # page would evict it. Never do that silently.
            print("  mcp-health: WARNING slug {0!r} is both a server and a "
                  "collision group; disambiguation page not written".format(bare))
            continue
        b[bare] = [{"alias": entries}]
    for i in range(SHARDS):
        name = "{0:02x}".format(i)
        (sd / (name + ".json")).write_text(
            json.dumps(buckets.get(name, {})), encoding="utf-8")

    # /mcp-health/diagnose/ -- the four failure causes in prose, plus the two
    # files the CLI is. The tool is run once by somebody who already has the
    # problem; this page is the surface a search for the error string can land
    # on, which is the whole reason it exists separately from the tool's --help.
    dd = d / "diagnose"
    dd.mkdir(parents=True, exist_ok=True)
    dld = {"@context": "https://schema.org", "@type": "TechArticle",
           "headline": "Why does my MCP server say \u201cconnection closed\u201d?",
           "description": "The four repeatable causes of an MCP server "
                          "failing to connect \u2014 a missing launcher, a "
                          "dependency break, a transport mismatch, and a bare "
                          "bash/sh launcher on Windows \u2014 each with the "
                          "command that identifies it.",
           "url": base + "/mcp-health/diagnose/",
           "dateModified": data["latest_date"],
           "license": base + "/mcp-health/diagnose/#licence",
           "isPartOf": base + "/mcp-health/",
           "author": {"@type": "Organization", "name": site.get("name"),
                      "url": base + "/"}}
    (dd / "index.html").write_text(R.page(
        "Why does my MCP server say \u201cconnection closed\u201d? — "
        + str(site.get("name") or ""),
        diagnose_html(data, site), site, dld,
        "The four repeatable causes of an MCP server failing to connect \u2014 "
        "a missing launcher, a dependency that moved, a transport mismatch, a "
        "bare bash/sh launcher on Windows \u2014 each with the one command "
        "that tells you which you have.",
        "/mcp-health/diagnose/",
        [("Home", "/"), ("MCP server health", "/mcp-health/"),
         ("Why it says connection closed", None)]), encoding="utf-8")
    (dd / "index.md").write_text(diagnose_md(data, site), encoding="utf-8")

    # The CLI itself, served from the page that documents it. Both files are
    # stdlib-only and hold no credentials; mcpdoctor.py imports mcphealth.py
    # for the prober, which is why both go. This is deliberately NOT a
    # rewritten copy: it is the same file the daily probe runs, so the verdicts
    # somebody gets locally are the verdicts on the scoreboard.
    here = pathlib.Path(__file__).resolve().parent
    shipped = 0
    if not site.get("code_licence"):
        print("  mcp-health: NOTE /mcp-health/diagnose/ offers two source "
              "files with no `code_licence` in site.json, so the page makes "
              "no licence claim about them. Joe decides "
              "(joeq: mcp-doctor-code-licence); set the key and re-render "
              "before deploying this page.")
    for fn in ("mcpdoctor.py", "mcphealth.py"):
        src = here / fn
        if src.exists():
            shutil.copyfile(str(src), str(dd / fn))
            shipped += 1
        else:
            print("  mcp-health: WARNING {0} not found; /mcp-health/diagnose/ "
                  "offers a download that will 404".format(fn))

    # The seeded per-server pages, if a cohort has been frozen. Read-only, and
    # filtered to slugs the Function can still serve: a seeded slug that has
    # since left the registry would be a 404 in the middle of the measurement.
    seed_urls = []
    seed = load_sitemap_seed()
    if seed:
        live = set(slugs.values()) | set(shared)
        alive = [s for s in seed["seeded"] if s in live]
        seed_urls = [(base + "/mcp-health/server/{0}/".format(
            urllib.parse.quote(s)), data["latest_date"]) for s in alive]
        if verbose:
            print("  mcp-health: {0} of {1} seeded server page(s) in the "
                  "sitemap ({2} slug(s) have left the registry since {3})"
                  .format(len(alive), len(seed["seeded"]),
                          len(seed["seeded"]) - len(alive), seed["created"]))

    urls = ([(base + "/mcp-health/", data["latest_date"]),
             (base + "/mcp-health/diagnose/", data["latest_date"])]
            + list_urls + seed_urls)
    if verbose:
        print("  mcp-health: /mcp-health/ + diagnose/ (+{5} source file(s)) + "
              "feed.json + {0} browse pages + "
              "{1} shards ({2:,} endpoints, {3:,} names, {4} day(s) of "
              "history)".format(pages, SHARDS, data["endpoints"], len(browse),
                                data["days_of_history"], shipped))
    return urls


# --------------------------------------------------------------------------


def report():
    data = load_latest()
    if not data:
        print("no probe days yet — run --probe")
        return 1
    n = data["endpoints"]
    print("mcp-health: {0:,} endpoints, {1} day(s) of history, latest {2}"
          .format(n, data["days_of_history"], data["latest_date"]))
    for c in CLASSES:
        v = data["counts"].get(c, 0)
        print("  {0:<12} {1:6,}  {2:5.1f}%  {3}".format(
            c, v, _pct(v, n), CLASS_LABEL[c]))
    lat = sorted([s["p50_ms"] for s in data["servers"] if s.get("p50_ms")])
    if lat:
        print("  latency p50 {0} ms, p95 {1} ms (healthy endpoints)".format(
            lat[len(lat) // 2], lat[int(len(lat) * 0.95)]))
    bad = [s for s in data["servers"] if (s.get("bad_schema") or 0) > 0]
    if bad:
        print("  {0:,} healthy server(s) expose at least one tool with no "
              "name or no object inputSchema".format(len(bad)))
    dr = data.get("drift")
    if dr is None:
        print("  tool-contract drift: not tracked on any day file yet — it "
              "needs two probe passes since fingerprinting was added")
    elif dr.get("days_tracked", 0) < 2:
        print("  tool-contract drift: baseline taken {0} for {1:,} endpoint(s);"
              " the next pass is the first that can report a change"
              .format(dr.get("tracking_since"), dr.get("tracked") or 0))
    else:
        print("  tool-contract drift: {0:,} endpoint(s) moved on the latest "
              "pass; {1:,} of {2:,} tracked moved at least once over {3} day(s)"
              " (+{4:,} tools, -{5:,}, {6:,} changed in place, {7:,} moved on "
              "more than one day)".format(
                  dr.get("latest_day") or 0, dr.get("servers") or 0,
                  dr.get("tracked") or 0, dr.get("days_tracked"),
                  (dr.get("tools") or {}).get("added") or 0,
                  (dr.get("tools") or {}).get("removed") or 0,
                  (dr.get("tools") or {}).get("changed") or 0,
                  dr.get("repeat") or 0))
    return 0


def drift_report(limit=30):
    """What moved, most-changed first. The list is the point, not the total."""
    data = load_latest()
    if not data:
        print("no probe days yet — run --probe")
        return 1
    dr = data.get("drift")
    if not dr:
        print("tool-contract drift: no day file carries a fingerprint yet. "
              "Run --probe twice; the first pass takes the baseline.")
        return 1
    print("tool-contract drift: tracking {0:,} endpoint(s) since {1}, "
          "{2} day(s)".format(dr.get("tracked") or 0, dr.get("tracking_since"),
                              dr.get("days_tracked")))
    moved = sorted([s for s in data["servers"] if s.get("drift")],
                   key=lambda s: (-(s["drift"].get("added", 0)
                                    + s["drift"].get("removed", 0)
                                    + s["drift"].get("changed", 0)),
                                  s["name"]))
    if not moved:
        print("  nothing has moved in the window.")
        return 0
    print("  {0:,} endpoint(s) moved; showing {1}".format(
        len(moved), min(limit, len(moved))))
    print("  {0:<4} {1:<4} {2:<4} {3:<10} {4:<5} {5}".format(
        "+", "-", "~", "since", "days", "name"))
    for s in moved[:limit]:
        d = s["drift"]
        print("  {0:<4} {1:<4} {2:<4} {3:<10} {4:<5} {5}".format(
            d.get("added") or 0, d.get("removed") or 0, d.get("changed") or 0,
            d.get("since") or "—", s.get("drift_days") or 1, s["name"]))
    rep = [s for s in moved if (s.get("drift_days") or 0) > 1]
    if rep:
        # Worth its own line: an endpoint that moves every day is almost never
        # a publisher shipping daily. It is a description or a schema carrying
        # something volatile, and treating it as news is how a drift feed
        # becomes the thing people mute.
        print("  {0:,} moved on more than one day — check those for a "
              "timestamp or session id inside a tool description before "
              "reading them as releases".format(len(rep)))
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--registry", action="store_true",
                    help="refresh the endpoint list from the official registry")
    ap.add_argument("--probe", action="store_true", help="run today's pass")
    ap.add_argument("--render", action="store_true",
                    help="write site/mcp-health/")
    ap.add_argument("--report", action="store_true")
    ap.add_argument("--drift", action="store_true",
                    help="which endpoints changed their tool contract, and by "
                         "how much")
    ap.add_argument("--daily", action="store_true",
                    help="registry + probe + rebuild latest.json (the cron job)")
    ap.add_argument("--limit", type=int, default=None)
    ap.add_argument("--workers", type=int, default=48)
    ap.add_argument("--minutes", type=int, default=None,
                    help="wall-clock ceiling; unreached endpoints are absent, "
                         "never recorded as failures")
    ap.add_argument("--out", type=pathlib.Path, default=ROOT / "site")
    ap.add_argument("--seed-sitemap", type=int, metavar="N", default=None,
                    help="freeze N per-server pages into the sitemap (and N "
                         "more as a held-back control) for the discovery "
                         "experiment. Run ONCE — see sitemap-seed.json")
    a = ap.parse_args()

    if not any([a.registry, a.probe, a.render, a.report, a.drift, a.daily,
                a.seed_sitemap]):
        ap.print_help()
        return 2
    if a.seed_sitemap:
        if SITEMAP_SEED.exists():
            print("mcp-health: {0} already exists. A cohort that moves "
                  "between runs measures nothing — delete it deliberately if "
                  "you really mean to start the experiment over."
                  .format(SITEMAP_SEED))
            return 1
        return seed_sitemap_cohorts(a.seed_sitemap)
    if a.registry or a.daily:
        fetch_registry()
    if a.probe or a.daily:
        run_probe(limit=a.limit, workers=a.workers, minutes=a.minutes)
        build_latest()
    if a.render:
        build_latest()
        render_site(a.out)
    if a.report:
        return report()
    if a.drift:
        return drift_report()
    return 0


if __name__ == "__main__":
    sys.exit(main())
