"""The sealed rule for "does an MCP server card mean there is a server?".

Follow-up of rule.agent-cards, sealed after that scan and before any of these requests. What I had
seen when sealing: research/agent-cards/results.json, meaning each positive's domain, rank, size,
final URL and whether $schema is exact. I had not opened a card's body. I have sent no MCP request
to any of these hosts.

Claim tested. A site that publishes an MCP server card has an MCP server there: an agent that reads
the card can connect. The scan counted files; this asks what stands behind them.

Frame. Domains in SAMPLE_A of rule.agent-cards whose mcp_card held (68). The bodies saved by that scan,
not refetched, so the card is the one the scan counted.

1. urls_of(card): every https URL under remotes[].url, url, endpoint, transport.url, transport.endpoint,
   endpoints.* of the card. A URL holding "{" is TEMPLATED and not probed.
2. A domain's card is NO_URL if urls_of gives nothing and nothing templated; ONLY_TEMPLATED if all are
   templated. Those two are counted, and left out of the denominator: this card cannot be tested.
3. Each URL gets one POST of an MCP initialize (INITIALIZE below), honest User-Agent, TIMEOUT_S, body read
   to MAX_BODY. classify() names the answer:
     SPEAKS   status 200 and a JSON-RPC 2.0 message in the body, plain or as an event stream, or any
              status with a JSON-RPC 2.0 error body;
     LOCKED   401 or 403 carrying a WWW-Authenticate header (a door with a lock is a door);
     NOTHING  anything else, no answer included.
4. RETRY_MIN minutes later, every URL that gave NOTHING is asked once more; the better answer stands.
5. A domain has a DOOR if any of its URLs is SPEAKS or LOCKED.
6. Estimate: DOOR domains / domains with a probed URL, Wilson 95 %. Bar 80 %, my own reading of "a card
   means a server": one card in five leading nowhere would make the file not worth trusting without
   a connection. Verdict: killed if the upper bound is below 80 %; survived if the lower bound is above
   80 %; inconclusive otherwise.

Void if any of:
V1 Fewer than 30 domains have a probed URL.
V2 fixtures.py, over real HTTP through this pack's own code, comes back with any wrong class.

Printed only, cannot move the verdict: the classes one by one; NO_URL and ONLY_TEMPLATED counts; SPEAKS
against LOCKED; the serverInfo names in SPEAKS answers; first capture date in the Internet Archive of each
domain's card path with status 200 (from its CDX API), how many fall before 17 April 2026, the date of
Cloudflare's post, and how many have none; the same probe on cards from SAMPLE_B outside A; a hand reading
of the cards, including any wording shared across unrelated sites.

Limits, said before the data: a server may refuse this client for reasons of its own, such as an
allow-list of clients, without a WWW-Authenticate header, and that reads as NOTHING; initialize is
the handshake of MCP as I read it today and a server on a later revision could reject it, though it would
then answer with a JSON-RPC error, which counts. LOCKED shows a door, not that anything is behind it.
No tool was listed and none was called; this is one handshake per URL and nothing further.
"""
import json

TIMEOUT_S = 10
MAX_BODY = 262144
RETRY_MIN = 30
BAR = 0.80
INITIALIZE = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
              "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                         "clientInfo": {"name": "untilnextsession-research", "version": "1.0"}}}


def urls_of(card):
    """(https URLs to probe, templated URLs)."""
    found = []
    def add(v):
        if isinstance(v, str) and v.startswith("https://"):
            found.append(v)
    remotes = card.get("remotes")
    if isinstance(remotes, list):
        for r in remotes:
            if isinstance(r, dict):
                add(r.get("url"))
    add(card.get("url"))
    add(card.get("endpoint"))
    transport = card.get("transport")
    if isinstance(transport, dict):
        add(transport.get("url"))
        add(transport.get("endpoint"))
    endpoints = card.get("endpoints")
    if isinstance(endpoints, dict):
        for v in endpoints.values():
            add(v)
    probe = sorted({u for u in found if "{" not in u})
    templated = sorted({u for u in found if "{" in u})
    return probe, templated


def _is_jsonrpc(obj):
    return isinstance(obj, dict) and obj.get("jsonrpc") == "2.0" and ("result" in obj or "error" in obj)


def _messages(body):
    """JSON-RPC candidates in a body: the body itself, or each data: line of an event stream."""
    try:
        text = body.decode("utf-8-sig")
    except UnicodeDecodeError:
        return []
    out = []
    try:
        out.append(json.loads(text))
    except (ValueError, RecursionError):
        for line in text.splitlines():
            if line.startswith("data:"):
                try:
                    out.append(json.loads(line[5:].strip()))
                except (ValueError, RecursionError):
                    pass
    return out


def classify(status, headers, body):
    """Rule step 3. `headers` has lowercase keys."""
    if status is None:
        return "NOTHING"
    msgs = [m for m in _messages(body or b"") if _is_jsonrpc(m)]
    if status == 200 and msgs:
        return "SPEAKS"
    if any("error" in m for m in msgs):
        return "SPEAKS"
    if status in (401, 403) and "www-authenticate" in headers:
        return "LOCKED"
    return "NOTHING"


def server_name(body):
    for m in _messages(body or b""):
        if _is_jsonrpc(m) and isinstance(m.get("result"), dict):
            info = m["result"].get("serverInfo")
            if isinstance(info, dict) and isinstance(info.get("name"), str):
                return info["name"][:80]
    return None


def wilson(k, n, z=1.959964):
    if n == 0:
        return (0.0, 1.0)
    p = k / n
    den = 1 + z * z / n
    mid = (p + z * z / (2 * n)) / den
    half = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) / den
    return (max(0.0, mid - half), min(1.0, mid + half))


def verdict(k, n):
    lo, hi = wilson(k, n)
    if hi < BAR:
        return "killed"
    if lo > BAR:
        return "survived"
    return "inconclusive"
