hypgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hypgraph/mcp.py ADDED
@@ -0,0 +1,461 @@
1
+ """
2
+ ``hypgraph mcp`` — the research graph as an MCP server, for agents (needs ``pip install "hypgraph[mcp]"``).
3
+
4
+ claude mcp add hypgraph -- hypgraph --db study.hypgraph --as claude mcp
5
+
6
+ The server writes as ONE author, fixed when it starts (``--as``, else $HYPGRAPH_AUTHOR, else "agent"): an agent can't
7
+ pass someone else's name, can't write as the instrument or the operator, and — unless the investigation lists it as
8
+ a reviewer — its verdicts are comments. Claims it writes are checked like anyone's: every number bound to a result,
9
+ no absolutes without k of n, exclusions need a powered search. A refusal comes back as a tool error that says what to
10
+ fix, which is the point.
11
+
12
+ Tools: overview · find · show · start_investigation · add · link · revise · withdraw · retract_link · review ·
13
+ next_experiments · work_queue · claim_work · register_run · report · verify · schema.
14
+ Resources: hypgraph://skill · hypgraph://schema · hypgraph://investigations · hypgraph://investigation/{inv}/report.
15
+ Prompt: method — how to work a question through the record.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+
26
+ from . import __version__
27
+ from .graph import Graph, GraphError, file_sha256
28
+ from .schema import TYPES, describe
29
+
30
+ INSTRUCTIONS = """HypGraph is a research record that can't flatter itself. Work a question through it:
31
+ 1. overview() — read what is already known before re-deriving anything.
32
+ 2. add hypotheses with predictions; add experiments with `tests` links whose data.expected says what each hypothesis
33
+ predicts BEFORE you run anything (that is what makes it a test).
34
+ 3. register_run() for computations you ran (it hashes the files); add results with values {name: {value} | {k, n}}.
35
+ 4. add a claim only with its evidence: a `supports` link from a result (with why), a scope, and every number in the
36
+ statement bound to a result field in `measurements`. Absolutes ("never", "all") need k of n with k = 0 or n.
37
+ An exclusion needs a search shown to recover a planted answer (result power=demonstrated).
38
+ 5. You can't set standing. Reviews by the investigation's reviewers do; nobody reviews their own work.
39
+ When the record refuses a write, it says what to fix — fix that, don't route around it."""
40
+
41
+
42
+ def _line(n: dict, width: int = 110) -> str:
43
+ t = " ".join(str(n.get("title") or "").split())
44
+ t = t if len(t) <= width else t[:width - 1] + "…"
45
+ return f"{n['code'] or n['id']} [{n['type']}] {t}"
46
+
47
+
48
+ def overview_text(g: Graph, inv: str, reviewer: str = "", limit: int = 3200) -> str:
49
+ """The record around an investigation in ~3,000 characters: objective, live hypotheses, what's established and
50
+ how, what's shaky, who's on what, reviews waiting, next experiments."""
51
+ s_inv = g.get(inv)
52
+ b = s_inv["body"]
53
+ sub = g.subgraph(inv, types=["hypothesis", "claim"])
54
+ work, nxt = g.work_map(inv), g.next_experiments(inv, 4)
55
+ state = b.get("state") or "active"
56
+ L = [f"## {b.get('title', inv)} ({s_inv['code']}, {inv}, {state})",
57
+ f"Objective: {' '.join(str(b.get('objective', '')).split())[:300]}"]
58
+ if b.get("reviewers"):
59
+ L.append("Reviewers (their verdicts count): " + ", ".join(b["reviewers"]))
60
+ if state != "active":
61
+ L.append(f"This investigation is {state}: its record is fixed. Review it, and build on it in a follow-up.")
62
+ nodes = sub["nodes"]
63
+ live = lambda n: n["state"].get("lifecycle") == "active" # noqa: E731
64
+ hyps = [n for n in nodes if n["type"] == "hypothesis" and live(n) and not n["state"].get("fallen")]
65
+ if hyps:
66
+ L.append(f"Live hypotheses ({len(hyps)}):")
67
+ L += [f" {_line(n)} — {n['state']['sentence'][:150]}" for n in hyps[:6]]
68
+ claims = [n for n in nodes if n["type"] == "claim" and live(n)]
69
+ checked = [n for n in claims if n["state"].get("review", {}).get("state") == "checked"]
70
+ if checked:
71
+ L.append(f"Established — checked claims ({len(checked)}):")
72
+ L += [f" {_line(n, 120)} — {(n['state'].get('evidence') or '').replace('_', ' ')}, "
73
+ f"{n['state'].get('independent_lines', 0)} line(s)" for n in checked[:8]]
74
+ open_claims = [n for n in claims if n not in checked]
75
+ if open_claims:
76
+ L.append("Other live claims: " + "; ".join(f"{n['code']} ({n['state']['review']['state']})"
77
+ for n in open_claims[:12]))
78
+ shaky = [n for n in claims if n["state"].get("review", {}).get("state") == "contested" or n["state"].get("at_risk")]
79
+ if shaky:
80
+ L.append("Contested or AT RISK — don't cite without checking: " + ", ".join(n["code"] for n in shaky[:20]))
81
+ if work["leases"]:
82
+ L.append("Being worked: " + "; ".join(f"{x['agent']} → {x['node']['code']}" for x in work["leases"][:8]))
83
+ if reviewer and reviewer in set(b.get("reviewers") or []) | set(g.policy.reviewers) and work["review_queue"]:
84
+ L.append(f"Reviews waiting for you ({len(work['review_queue'])}): "
85
+ + ", ".join(n["code"] for n in work["review_queue"][:10]))
86
+ if work["revalidation"]:
87
+ L.append("Needs revalidation (something it rests on moved): "
88
+ + ", ".join(n["code"] for n in work["revalidation"][:10]))
89
+ if work["decisions_to_revisit"]:
90
+ L.append("Decisions to revisit (their basis changed): "
91
+ + ", ".join(n["code"] for n in work["decisions_to_revisit"][:10]))
92
+ if nxt["ranked"] and state == "active":
93
+ L.append("Next experiments: " + "; ".join(
94
+ f"{r['experiment']['code']} (separates {r['separates']}{', powered' if r['power_check'] else ''}"
95
+ f"{', DUPLICATE' if r['duplicate'] else ''})" for r in nxt["ranked"][:4]))
96
+ if nxt["untested_hypotheses"] and state == "active":
97
+ L.append("Hypotheses no experiment tests yet: " + ", ".join(h["code"] for h in nxt["untested_hypotheses"]))
98
+ text = "\n".join(L)
99
+ return text if len(text) <= limit else text[:limit - 30] + "\n … (find / show for more)"
100
+
101
+
102
+ def show_text(n: dict) -> str:
103
+ st = n.get("state") or {}
104
+ out = [f"{n['code']} ({n['id']}) — {n['type']} v{n['v']} by {n['by']} {n['at'][:10]}",
105
+ f"standing: {st.get('sentence', '')}"]
106
+ for k, v in (n.get("body") or {}).items():
107
+ if k != "legacy":
108
+ out.append(f" {k}: {v[:700] if isinstance(v, str) else json.dumps(v, ensure_ascii=False)[:700]}")
109
+ if n.get("withdrawn"):
110
+ out.append(f" WITHDRAWN by {n['withdrawn']['by']}: {n['withdrawn']['why']}")
111
+ outs = [f" this -{e['rel']}-> {e['dst']}" + (f" ({e['why'][:90]})" if e.get("why") else "")
112
+ for e in n.get("out", [])]
113
+ ins = [f" {e['src']} -{e['rel']}-> this" + (f" ({e['why'][:90]})" if e.get("why") else "")
114
+ for e in n.get("in", [])]
115
+ if outs:
116
+ out += [" links out:"] + outs[:25]
117
+ if ins:
118
+ out += [" links in:"] + ins[:25]
119
+ return "\n".join(out)
120
+
121
+
122
+ def build(graph: Graph | str, author: Optional[str] = None, files_root: str | None = None):
123
+ """The FastMCP server over one graph, writing as one author."""
124
+ try:
125
+ from fastmcp import FastMCP
126
+ from fastmcp.exceptions import ToolError
127
+ except ImportError as e: # pragma: no cover
128
+ raise SystemExit('the MCP server needs FastMCP: pip install "hypgraph[mcp]"') from e
129
+ g = graph if isinstance(graph, Graph) else Graph(graph)
130
+ me = (author or os.getenv("HYPGRAPH_AUTHOR") or "agent").strip().lower()[:40]
131
+ if me in g.policy.reserved:
132
+ raise SystemExit(f"{me!r} is a reserved author ({', '.join(sorted(g.policy.reserved))}); pick another --as")
133
+ root = Path(files_root or os.getcwd()).resolve()
134
+ mcp = FastMCP("hypgraph", instructions=INSTRUCTIONS + f"\nYou write as {me!r}.", version=__version__,
135
+ website_url="https://hypgraph.com")
136
+
137
+ def fail(e: Exception):
138
+ raise ToolError(str(e) if not isinstance(e, KeyError) else f"not found: {e.args[0] if e.args else e}")
139
+
140
+ def inv_of(ref: str) -> str:
141
+ if ref:
142
+ return g.resolve(ref)
143
+ invs = [i for i in g.investigations() if i["state"]["lifecycle"] == "active"] or g.investigations()
144
+ if len(invs) == 1:
145
+ return invs[0]["id"]
146
+ if not invs:
147
+ raise GraphError("no investigation yet — start_investigation(title, objective)")
148
+ raise GraphError("which investigation? " + "; ".join(f"{i['code']} {i['id']} — {i['title'][:50]}" for i in invs))
149
+
150
+ def resolve_links(links: list[dict] | None, inv: str) -> list[dict]:
151
+ out = []
152
+ for raw in links or []:
153
+ ln = dict(raw)
154
+ for k in ("to", "from"):
155
+ if ln.get(k):
156
+ ln[k] = g.resolve(str(ln[k]), inv)
157
+ out.append(ln)
158
+ return out
159
+
160
+ @mcp.tool
161
+ def overview(investigation: str = "") -> str:
162
+ """Read this first. The record around an investigation: objective, reviewers, live hypotheses, established
163
+ claims (and how they're known), contested and at-risk work, who's on what, reviews and revalidations
164
+ waiting, next experiments. With several investigations and none named, lists them."""
165
+ try:
166
+ if not investigation:
167
+ invs = g.investigations()
168
+ if len(invs) != 1:
169
+ return "\n".join(f"{i['code']} {i['id']} — {i['title']} ({i['state']['lifecycle']}, "
170
+ f"{sum(i['counts'].values())} nodes)" for i in invs) or \
171
+ "No investigations yet — start_investigation(title, objective, reviewers)."
172
+ return overview_text(g, inv_of(investigation), reviewer=me)
173
+ except (GraphError, KeyError) as e:
174
+ fail(e)
175
+
176
+ @mcp.tool
177
+ def find(text: str = "", type: str = "", investigation: str = "", limit: int = 20) -> str:
178
+ """Search the record by words or code (H1, CL3 …). type filters: investigation, question, hypothesis, claim,
179
+ experiment, run, result, artifact, source, review, decision, task."""
180
+ try:
181
+ rows = g.search(text=text, type=type, inv=g.resolve(investigation) if investigation else "",
182
+ limit=max(1, min(int(limit), 100)))
183
+ except (GraphError, KeyError) as e:
184
+ fail(e)
185
+ return "\n".join(f"{_line(n)} · {n['id']} · {n['state']['sentence'][:150]}" for n in rows) or "Nothing found."
186
+
187
+ @mcp.tool
188
+ def show(node: str, view: str = "summary", investigation: str = "") -> str:
189
+ """One node by id or code. view: summary (fields, standing, links) | why (what it rests on, down to runs, data
190
+ and sources) | impact (what rests on it — what breaks if it's wrong) | history (its versions)."""
191
+ try:
192
+ nid = g.resolve(node, g.resolve(investigation) if investigation else "")
193
+ if view == "why":
194
+ r = g.why(nid)
195
+ return f"WHY {node} — what it rests on:\n" + "\n".join(
196
+ f" {_line(n)} — {n['state']['sentence'][:150]}" for n in r["nodes"] if n["id"] != nid) \
197
+ + "\n edges:\n" + "\n".join(f" {e['src']} -{e['rel']}-> {e['dst']}" for e in r["edges"][:60])
198
+ if view == "impact":
199
+ r = g.impact(nid)
200
+ return (f"IF {node} IS WRONG — {len(r['dependents'])} node(s) rest on it:\n" + "\n".join(
201
+ f" {' ' * (d['depth'] - 1)}{d['rel']} ← {_line(d['node'])}" for d in r["dependents"][:40])) \
202
+ if r["dependents"] else f"Nothing rests on {node}."
203
+ if view == "history":
204
+ n = g.get(nid, history=True)
205
+ return show_text(n) + "\n versions:\n" + "\n".join(
206
+ f" v{v['v']} {v['at'][:10]} {v['by']}: {v['reason'] or '(created)'}" for v in n["versions"])
207
+ return show_text(g.get(nid))
208
+ except (GraphError, KeyError) as e:
209
+ fail(e)
210
+
211
+ @mcp.tool
212
+ def start_investigation(title: str, objective: str, reviewers: list[str] | None = None, scope: str = "",
213
+ success_criteria: str = "") -> str:
214
+ """Open an investigation: what you want to find out (objective, a sentence), its scope, what would count as
215
+ an answer, and who reviews it (their verdicts set the standing; you can't review your own work)."""
216
+ try:
217
+ n = g.create(author=me, type="investigation", fields={
218
+ "title": title, "objective": objective, "scope": scope, "success_criteria": success_criteria,
219
+ "reviewers": [r.strip().lower() for r in reviewers or [] if r.strip()]})
220
+ except GraphError as e:
221
+ fail(e)
222
+ return f"Opened {n['code']} ({n['id']}). Next: add questions and hypotheses (with predictions)."
223
+
224
+ @mcp.tool
225
+ def add(type: str, fields: dict[str, Any], investigation: str = "", links: list[dict[str, Any]] | None = None) -> str:
226
+ """Add a node. type: question {text} · hypothesis {statement, predictions[]} · experiment {aim, protocol,
227
+ success_criteria, controls, method, power_check, cost_minutes} with `tests` links carrying data.expected per
228
+ hypothesis · run {kind: agent, name, state} (prefer register_run) · result {summary, values {name: {value} |
229
+ {k, n}}, outcome, power} · claim {statement, scope, kind finding|exclusion|inconclusive|convention|lead,
230
+ measurements [{name, value | k, n, result: <id>, key: <values field>}]} with a `supports` link from its
231
+ result · source {citation, doi, url, passage} · decision {choice, reasons, alternatives} with `based_on`.
232
+ links: [{"rel": …, "to": id-or-code} (from this node) or {"rel": …, "from": id-or-code, "why": "a sentence"}
233
+ (into it), optional "data": {...}]."""
234
+ try:
235
+ if type not in TYPES or type in ("investigation", "review"):
236
+ raise GraphError("type: " + ", ".join(t for t in TYPES if t not in ("investigation", "review"))
237
+ + " (start_investigation / review for the others)")
238
+ inv = inv_of(investigation)
239
+ n = g.create(author=me, type=type, fields=fields, inv=inv, links=resolve_links(links, inv))
240
+ except (GraphError, KeyError) as e:
241
+ fail(e)
242
+ notes = "".join(f" Note: {x}." for x in n.get("notes") or [])
243
+ return f"Added {n['code']} ({n['id']}) — {n['state']['sentence']}.{notes}"
244
+
245
+ @mcp.tool
246
+ def link(src: str, rel: str, dst: str, why: str = "", data: dict[str, Any] | None = None,
247
+ investigation: str = "") -> str:
248
+ """Connect two nodes. rel: supports | challenges (why required) | requires | tests (data.expected) | executes
249
+ | produced | uses (data.role) | derived_from | replicates (data.differs: same_code | new_seed | new_impl |
250
+ new_data | new_env) | supersedes | corrects | answers | part_of | alternative_to | motivated_by | cites |
251
+ based_on. A link given backwards is turned round when only the reverse is allowed."""
252
+ try:
253
+ inv = g.resolve(investigation) if investigation else ""
254
+ r = g.link(author=me, src=g.resolve(src, inv), rel=rel.strip().lower(), dst=g.resolve(dst, inv), why=why,
255
+ data=data or {})
256
+ except (GraphError, KeyError) as e:
257
+ fail(e)
258
+ return f"Linked {r['src']} -{r['rel']}-> {r['dst']} (edge {r['edge']})" + \
259
+ (" — turned round to the direction the relation allows." if r["flipped"] else ".")
260
+
261
+ @mcp.tool
262
+ def revise(node: str, fields: dict[str, Any], reason: str, investigation: str = "") -> str:
263
+ """A new version of a node you wrote (or review): the given fields replace the old ones. Reviews of the old
264
+ version stay on record but go stale; decisions based on it are flagged to revisit."""
265
+ try:
266
+ n = g.revise(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""),
267
+ fields=fields, reason=reason)
268
+ except (GraphError, KeyError) as e:
269
+ fail(e)
270
+ return f"{n['code']} is now v{n['v']} — {n['state']['sentence']}"
271
+
272
+ @mcp.tool
273
+ def withdraw(node: str, why: str, investigation: str = "") -> str:
274
+ """Withdraw a node you wrote. It stays on record, marked withdrawn; whatever rested on it is flagged AT RISK."""
275
+ try:
276
+ n = g.withdraw(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""), why=why)
277
+ except (GraphError, KeyError) as e:
278
+ fail(e)
279
+ return f"{n['code']} — {n['state']['sentence']}"
280
+
281
+ @mcp.tool
282
+ def retract_link(edge: int, why: str) -> str:
283
+ """Retract a link you made (by edge id), saying why it no longer holds. The retraction is on record."""
284
+ try:
285
+ g.retract(author=me, edge=int(edge), why=why)
286
+ except GraphError as e:
287
+ fail(e)
288
+ return f"Retracted edge {edge}."
289
+
290
+ @mcp.tool
291
+ def review(node: str, verdict: str, method: str, notes: str, investigation: str = "") -> str:
292
+ """Review a node's current version: checked | pass | reproduced | replicated | separate_implementation |
293
+ generalizes | fail | not_reproduced | not_replicated | does_not_generalize | objection | resolved |
294
+ inconclusive | comment. Say how you checked (method) and what you found (notes). Only the investigation's
295
+ reviewers' verdicts count; anyone else's are kept as comments. Not your own work."""
296
+ try:
297
+ n = g.review(author=me, target=g.resolve(node, g.resolve(investigation) if investigation else ""),
298
+ verdict=verdict.strip().lower(), method=method, notes=notes)
299
+ except (GraphError, KeyError) as e:
300
+ fail(e)
301
+ return f"Recorded on {n['code']}: {n['state']['sentence']}"
302
+
303
+ @mcp.tool
304
+ def next_experiments(investigation: str = "") -> str:
305
+ """Planned experiments ranked by how well they separate the live hypotheses (distinct expected outcomes),
306
+ whether they can show their own power, and cost; duplicates and work someone holds flagged; untested
307
+ hypotheses listed."""
308
+ try:
309
+ r = g.next_experiments(inv_of(investigation))
310
+ except (GraphError, KeyError) as e:
311
+ fail(e)
312
+ out = [f"Next experiments ({r['rule']}):"]
313
+ out += [f" {x['experiment']['code']} score {x['score']} — separates {x['separates']}, "
314
+ f"{'power check' if x['power_check'] else 'no power check'}, ~{int(x['cost_minutes'])} min"
315
+ + (" — DUPLICATE" if x["duplicate"] else "") + (f" — {x['lease']['agent']} on it" if x["lease"] else "")
316
+ + f": {x['experiment']['title'][:100]}" for x in r["ranked"]] or [" (none planned)"]
317
+ if r["untested_hypotheses"]:
318
+ out.append("Untested hypotheses: " + ", ".join(_line(h, 80) for h in r["untested_hypotheses"]))
319
+ return "\n".join(out)
320
+
321
+ @mcp.tool
322
+ def work_queue(investigation: str = "") -> str:
323
+ """What needs doing: leases (who's on what), the review queue, revalidations (something a node rests on
324
+ moved), contested work, decisions to revisit, duplicate experiments."""
325
+ try:
326
+ w = g.work_map(inv_of(investigation))
327
+ except (GraphError, KeyError) as e:
328
+ fail(e)
329
+ sec = lambda name, rows: f"{name} ({len(rows)}): " + (", ".join( # noqa: E731
330
+ f"{x['code']} {x['title'][:50]}" for x in rows[:12]) or "—")
331
+ return "\n".join(["Being worked: " + (", ".join(f"{x['agent']} → {x['node']['code']}" for x in w["leases"])
332
+ or "—"),
333
+ sec("Review queue", w["review_queue"]), sec("Needs revalidation", w["revalidation"]),
334
+ sec("Contested", w["contested"]), sec("Decisions to revisit", w["decisions_to_revisit"]),
335
+ f"Duplicate experiment groups: {len(w['duplicates'])}"])
336
+
337
+ @mcp.tool
338
+ def claim_work(node: str, release: bool = False, minutes: int = 240, investigation: str = "") -> str:
339
+ """Take a node (an experiment to run, a claim to review) so others see you're on it — or release it."""
340
+ try:
341
+ n = g.lease(author=me, node=g.resolve(node, g.resolve(investigation) if investigation else ""),
342
+ minutes=int(minutes), release=bool(release))
343
+ except (GraphError, KeyError) as e:
344
+ fail(e)
345
+ return f"{'Released' if release else 'Took'} {n['code']} — {n['state']['sentence']}"
346
+
347
+ @mcp.tool
348
+ def register_run(name: str, investigation: str = "", code_paths: list[str] | None = None,
349
+ input_paths: list[str] | None = None, output_paths: list[str] | None = None, command: str = "",
350
+ seed: str = "", experiment: str = "", result_summary: str = "",
351
+ result_values: dict[str, Any] | None = None, outcome: str = "", limitations: str = "") -> str:
352
+ """Record a computation you ran. The server hashes the files itself (code, inputs, outputs — paths under its
353
+ files root), records the run as agent-reported, and adds the result it produced, ready for claims to bind
354
+ to: result_values {name: {value, unit} | {k, n}}."""
355
+ try:
356
+ inv = inv_of(investigation)
357
+
358
+ def hashed(paths):
359
+ out = []
360
+ for p in paths or []:
361
+ q = (root / p).resolve() if not Path(p).is_absolute() else Path(p).resolve()
362
+ if not q.is_relative_to(root):
363
+ raise GraphError(f"{p}: only files under {root} can be registered")
364
+ if not q.is_file():
365
+ raise GraphError(f"{p}: no such file")
366
+ out.append(file_sha256(q) | {"path": str(q.relative_to(root))})
367
+ return out
368
+ files = {"code": hashed(code_paths), "inputs": hashed(input_paths), "outputs": hashed(output_paths)}
369
+ if not files["code"] and not command.strip():
370
+ raise GraphError("say what ran: code_paths (hashed) and/or command")
371
+ links = []
372
+ for role, key in (("code", "code"), ("data", "inputs")):
373
+ for f in files[key]:
374
+ a = g.create(author=me, type="artifact", inv=inv, fields={
375
+ "sha256": f["sha256"], "path": f["path"], "size": f["size"], "format": Path(f["path"]).suffix[1:]})
376
+ links.append({"rel": "uses", "to": a["id"], "data": {"role": role}})
377
+ for f in files["outputs"]:
378
+ a = g.create(author=me, type="artifact", inv=inv, fields={
379
+ "sha256": f["sha256"], "path": f["path"], "size": f["size"], "format": Path(f["path"]).suffix[1:]})
380
+ links.append({"rel": "produced", "to": a["id"]})
381
+ if experiment.strip():
382
+ links.append({"rel": "executes", "to": g.resolve(experiment, inv)})
383
+ fields: dict[str, Any] = {"kind": "agent", "name": name[:300], "state": "succeeded", **files,
384
+ "args": [command[:300]] if command.strip() else [],
385
+ "note": f"Registered by {me}; files hashed by the MCP server when registered."}
386
+ if seed.strip():
387
+ fields["seed"] = int(seed) if re.fullmatch(r"-?\d+", seed.strip()) else seed.strip()[:64]
388
+ run = g.create(author=me, type="run", inv=inv, fields=fields, links=links)
389
+ msg = f"Registered {run['code']} ({run['id']}): {sum(len(v) for v in files.values())} file(s) hashed."
390
+ if result_summary.strip():
391
+ rf: dict[str, Any] = {"summary": result_summary, "kind": "computed", "values": result_values or {}}
392
+ if outcome:
393
+ rf["outcome"] = outcome
394
+ if limitations:
395
+ rf["limitations"] = limitations
396
+ r = g.create(author=me, type="result", inv=inv, fields=rf, links=[{"rel": "produced", "from": run["id"]}])
397
+ msg += f" Result {r['code']} ({r['id']}) — bind claims to it with measurements."
398
+ return msg
399
+ except (GraphError, KeyError, OSError) as e:
400
+ fail(e)
401
+
402
+ @mcp.tool
403
+ def report(investigation: str = "") -> str:
404
+ """The facts of an investigation generated from the record (markdown): every claim with its standing and
405
+ verdicts, every result and whether a claim carries it, hypotheses, experiments (preregistered or not), open
406
+ items. Start any summary from it; it can't restate a withdrawn claim or override a verdict."""
407
+ try:
408
+ return g.report(inv_of(investigation))["markdown"]
409
+ except (GraphError, KeyError) as e:
410
+ fail(e)
411
+
412
+ @mcp.tool
413
+ def verify() -> str:
414
+ """Recompute the hash chain and check every stored version and link against the event that wrote it."""
415
+ v = g.verify()
416
+ return (f"Verified {v['checked']} events: chain intact, every row matches its event (head {v['head'][:16]}…)."
417
+ if v["ok"] else f"VERIFY FAILED: {v['problem']}")
418
+
419
+ @mcp.tool
420
+ def schema() -> dict:
421
+ """The node types (fields, which are required), the relations (which types each may join) and the verdicts."""
422
+ return describe()
423
+
424
+ @mcp.resource("hypgraph://schema", mime_type="application/json")
425
+ def schema_resource() -> str:
426
+ """The hypgraph/1 vocabulary."""
427
+ return json.dumps(describe(), indent=1)
428
+
429
+ @mcp.resource("hypgraph://investigations", mime_type="application/json")
430
+ def investigations_resource() -> str:
431
+ """Every investigation with its counts."""
432
+ return json.dumps([{k: i[k] for k in ("id", "code", "title", "counts")} | {"state": i["state"]["lifecycle"]}
433
+ for i in g.investigations()], indent=1)
434
+
435
+ @mcp.resource("hypgraph://investigation/{inv}/report", mime_type="text/markdown")
436
+ def report_resource(inv: str) -> str:
437
+ """The facts of one investigation, generated from the record."""
438
+ return g.report(g.resolve(inv))["markdown"]
439
+
440
+ @mcp.resource("hypgraph://skill", mime_type="text/markdown")
441
+ def skill_resource() -> str:
442
+ """The HypGraph agent skill (SKILL.md): the method, the refusals and their fixes."""
443
+ return (Path(__file__).parent / "skill" / "SKILL.md").read_text(encoding="utf-8")
444
+
445
+ @mcp.prompt
446
+ def method(question: str = "") -> str:
447
+ """How to work a question through the record."""
448
+ return (INSTRUCTIONS + "\n\n" + (f"The question: {question}\n" if question else "")
449
+ + "Start with overview(); if no investigation fits, start_investigation with reviewers named.")
450
+
451
+ mcp.graph = g
452
+ mcp.author = me
453
+ return mcp
454
+
455
+
456
+ def run(db: str, author: Optional[str] = None, transport: str = "stdio", port: int = 8766) -> None:
457
+ server = build(db, author)
458
+ if transport == "http":
459
+ server.run(transport="http", host="127.0.0.1", port=port)
460
+ else:
461
+ server.run(show_banner=False)