#!/usr/bin/env python3
"""solonic-mcp.py — Solonic Atlas MCP server. Single file, zero dependencies.

Speaks MCP over stdio (newline-delimited JSON-RPC 2.0). Runs entirely locally
against the downloadable corpus: no API key, no network, no Solonic in the loop.

Setup:
  1. Put this file next to atlas-merged-corpus.json and atlas-edges.json
     (both from https://solonic.ai/atlas-data.html)
  2. Register it, e.g. in an MCP client config:
     {"mcpServers": {"solonic-atlas":
        {"command": "python3", "args": ["/path/to/solonic-mcp.py"]}}}

The protocol shape targets the MCP stdio transport as of early 2026. If your
client handshakes differently, this file is short — read it and adapt it.
That is what "recipes for agents that want to code it themselves" means.
"""
import sys, json, os, math, hashlib

HERE = os.path.dirname(os.path.abspath(__file__))

def load(name):
    p = os.path.join(HERE, name)
    if os.path.exists(p):
        with open(p, encoding="utf-8") as f: return json.load(f)
    return None

CORPUS = load("atlas-merged-corpus.json")
EDGES  = load("atlas-edges.json")
NODES  = (CORPUS or {}).get("nodes", [])

TOOLS = [
 {"name":"atlas_stats","description":"Corpus totals: nodes, domains, open/established, sources, clusters. Recompute rather than trust the website.",
  "inputSchema":{"type":"object","properties":{},"additionalProperties":False}},
 {"name":"atlas_search","description":"Substring search over problem/result names and domains. Returns nid, name, domain, status.",
  "inputSchema":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer","default":10}},"required":["query"]}},
 {"name":"atlas_node","description":"Fetch one node by nid (e.g. 'o:his:historical_objectivity').",
  "inputSchema":{"type":"object","properties":{"nid":{"type":"string"}},"required":["nid"]}},
 {"name":"atlas_domains","description":"All 102 domains with node counts, grouped by cluster.",
  "inputSchema":{"type":"object","properties":{},"additionalProperties":False}},
 {"name":"atlas_bridge","description":"Recompute cross-domain edges from the typed edge list. The site claims exactly one. Check it.",
  "inputSchema":{"type":"object","properties":{},"additionalProperties":False}},
 {"name":"clock_crossover","description":"Saturation crossover year: T = ln(headroom)/ln((1+g_ai)/(1+g_h)), from 2026. The site's clock, recomputable.",
  "inputSchema":{"type":"object","properties":{"g_ai":{"type":"number","default":0.67},"g_h":{"type":"number","default":0.03},"headroom":{"type":"number","default":30}},"additionalProperties":False}},
 {"name":"mark_check","description":"Verify a Solonic Mark offline: SHA-256 a local file and compare to the hash from a registry record.",
  "inputSchema":{"type":"object","properties":{"path":{"type":"string"},"expected_sha256":{"type":"string"}},"required":["path","expected_sha256"]}},
 {"name":"neurath_schema","description":"The Neurath gate: tag taxonomy, rules, and the JSON output schema, so you can run the gate on your own output.",
  "inputSchema":{"type":"object","properties":{},"additionalProperties":False}},
]

def t_stats(_):
    if not CORPUS: return "corpus not found — download atlas-merged-corpus.json next to this file"
    m = CORPUS.get("_meta", {})
    from collections import Counter
    cl = Counter(n["cluster"] for n in NODES); st = Counter(n["status"] for n in NODES)
    return json.dumps({"nodes":len(NODES),"domains":m.get("domains"),"status":dict(st),
                       "clusters":dict(cl),"sources":m.get("sources"),
                       "known_gaps":m.get("known_gaps")}, indent=1)

def t_search(a):
    q = a.get("query","").lower(); lim = int(a.get("limit",10))
    hits = [{"nid":n["nid"],"name":n["name"],"domain":n["domLabel"],"status":n["status"]}
            for n in NODES if q in n["name"].lower() or q in n["domLabel"].lower()][:lim]
    return json.dumps(hits, indent=1) if hits else "no matches"

def t_node(a):
    nid = a.get("nid","")
    for n in NODES:
        if n["nid"] == nid: return json.dumps(n, indent=1)
    return "not found: " + nid

def t_domains(_):
    from collections import defaultdict
    d = defaultdict(lambda: defaultdict(int))
    for n in NODES: d[n["cluster"]][n["domLabel"]] += 1
    return json.dumps({c: dict(sorted(v.items())) for c, v in sorted(d.items())}, indent=1)

def t_bridge(_):
    if not EDGES: return "edges not found — download atlas-edges.json next to this file"
    nodes, edges = EDGES["nodes"], EDGES["edges"]
    cross = [{"from":nodes[a]["n"],"from_domain":nodes[a]["d"],
              "to":nodes[b]["n"],"to_domain":nodes[b]["d"],"type":t}
             for a,b,t in edges
             if nodes[a]["d"]!=nodes[b]["d"] and "hub" not in (nodes[a]["d"],nodes[b]["d"])]
    return json.dumps({"total_edges":len(edges),"cross_domain":len(cross),"bridges":cross}, indent=1)

def t_clock(a):
    g_ai=float(a.get("g_ai",0.67)); g_h=float(a.get("g_h",0.03)); H=float(a.get("headroom",30))
    r = math.log(1+g_ai)-math.log(1+g_h)
    if r<=0: return json.dumps({"crossover":"never — review outgrows generation under these inputs"})
    T = math.log(H)/r
    return json.dumps({"g_ai":g_ai,"g_h":g_h,"headroom":H,"years_to_crossover":round(T,2),
                       "crossover_year":round(2026+T),
                       "note":"scale-invariant in direction; the date is the soft part"}, indent=1)

def t_mark(a):
    p=a.get("path",""); exp=a.get("expected_sha256","").lower().strip()
    if not os.path.exists(p): return "file not found: "+p
    h=hashlib.sha256(open(p,"rb").read()).hexdigest()
    return json.dumps({"file":p,"sha256":h,"expected":exp,
                       "match":h==exp,
                       "verdict":"mark describes this exact version" if h==exp
                                 else "MISMATCH — the mark does not describe this file, whatever the badge says"}, indent=1)

def t_neurath(_):
    return json.dumps({
      "tags":{"PROVED":"deductive, machine- or referee-checked",
              "VERIFIED":"empirically confirmed against sources",
              "OPEN":"live claim or forecast; no verdict yet",
              "CORRECTED":"was wrong; fixed; original kept visible",
              "REFUTED":"evidence against; entered, not deleted"},
      "rules":["every claim carries exactly one tag","untagged output is a bug, not a result",
               "exact arithmetic; show recomputation","errata filed the day an error is found, original struck through not deleted",
               "the reviser never grades its own repair (Ouroboros rule)",
               "publish the entries that went against you or your record is survivorship"],
      "output_schema":{"claim":"string","tag":"PROVED|VERIFIED|OPEN|CORRECTED|REFUTED",
                       "evidence":"string — what was checked, against what, what it returned",
                       "falsifier":"string — what observation would overturn this"}}, indent=1)

HANDLERS={"atlas_stats":t_stats,"atlas_search":t_search,"atlas_node":t_node,
          "atlas_domains":t_domains,"atlas_bridge":t_bridge,"clock_crossover":t_clock,
          "mark_check":t_mark,"neurath_schema":t_neurath}

def reply(id_, result=None, error=None):
    m={"jsonrpc":"2.0","id":id_}
    if error: m["error"]=error
    else: m["result"]=result
    sys.stdout.write(json.dumps(m)+"\n"); sys.stdout.flush()

def main():
    for line in sys.stdin:
        line=line.strip()
        if not line: continue
        try: msg=json.loads(line)
        except json.JSONDecodeError: continue
        mid=msg.get("id"); method=msg.get("method","")
        if method=="initialize":
            reply(mid, {"protocolVersion":msg.get("params",{}).get("protocolVersion","2024-11-05"),
                        "capabilities":{"tools":{}},
                        "serverInfo":{"name":"solonic-atlas","version":"0.1.0"}})
        elif method=="notifications/initialized": pass
        elif method=="ping": reply(mid, {})
        elif method=="tools/list": reply(mid, {"tools":TOOLS})
        elif method=="tools/call":
            p=msg.get("params",{}); name=p.get("name"); args=p.get("arguments",{}) or {}
            fn=HANDLERS.get(name)
            if not fn: reply(mid, error={"code":-32602,"message":"unknown tool: "+str(name)})
            else:
                try: reply(mid, {"content":[{"type":"text","text":fn(args)}],"isError":False})
                except Exception as e: reply(mid, {"content":[{"type":"text","text":"error: "+str(e)}],"isError":True})
        elif mid is not None:
            reply(mid, error={"code":-32601,"message":"method not found: "+method})

if __name__=="__main__": main()
