toolgraph 0.0.1__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.
@@ -0,0 +1,280 @@
1
+ """Load factual crawl results into the graph (idempotent).
2
+
3
+ Only crawled facts land here: MCPServer/Tool/Resource nodes and the
4
+ EXPOSES/PROVIDES edges. Authored governance edges are handled by manifest.ingest.
5
+
6
+ Idempotency: every node is MERGEd on its unique key (server name, tool
7
+ ``key``, resource uri), so re-crawling identical data never duplicates.
8
+ ``input_schema`` is JSON-serialized because Neo4j properties cannot hold nested
9
+ maps.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ from neo4j import ManagedTransaction
17
+
18
+ from toolgraph.graph.driver import session
19
+ from toolgraph.graph.schema import bump_generation, tool_key
20
+ from toolgraph.models import CrawlResult
21
+ from toolgraph.redaction import persisted_endpoint
22
+ from toolgraph.uris import normalize_resource_uri
23
+
24
+
25
+ def _merge_crawl(tx: ManagedTransaction, result: CrawlResult) -> list[str]:
26
+ endpoint = persisted_endpoint(result.transport, result.endpoint)
27
+ tx.run(
28
+ """
29
+ MERGE (s:MCPServer {name:$name})
30
+ SET s.version=$version, s.transport=$transport,
31
+ s.endpoint=$endpoint, s.last_crawled_at=datetime()
32
+ """,
33
+ name=result.server_name,
34
+ version=result.server_version,
35
+ transport=result.transport,
36
+ endpoint=endpoint,
37
+ )
38
+
39
+ tools = [
40
+ {
41
+ "key": tool_key(result.server_name, t.name),
42
+ "name": t.name,
43
+ "description": t.description,
44
+ "input_schema": json.dumps(t.input_schema) if t.input_schema is not None else None,
45
+ "read_only_hint": t.read_only_hint,
46
+ "destructive_hint": t.destructive_hint,
47
+ "idempotent_hint": t.idempotent_hint,
48
+ "open_world_hint": t.open_world_hint,
49
+ }
50
+ for t in result.tools
51
+ ]
52
+ # EXPOSES carries crawled provenance: source/confidence/evidence so a reader
53
+ # can distinguish facts (here) from operator assertions (CAN_CALL etc.).
54
+ # Annotation hints (ADR-0006) are SET unconditionally: a null value removes
55
+ # the property, so a hint the server stopped sending is cleared on the same
56
+ # crawl pass that owns the node (same lifecycle as description).
57
+ if tools:
58
+ tx.run(
59
+ """
60
+ MATCH (s:MCPServer {name:$name})
61
+ UNWIND $tools AS t
62
+ MERGE (tool:Tool {key:t.key})
63
+ SET tool.name=t.name, tool.server=$name,
64
+ tool.description=t.description, tool.input_schema=t.input_schema,
65
+ tool.read_only_hint=t.read_only_hint,
66
+ tool.destructive_hint=t.destructive_hint,
67
+ tool.idempotent_hint=t.idempotent_hint,
68
+ tool.open_world_hint=t.open_world_hint
69
+ MERGE (s)-[e:EXPOSES]->(tool)
70
+ SET e.source='crawled', e.confidence='high', e.evidence=$evidence
71
+ """,
72
+ name=result.server_name,
73
+ tools=tools,
74
+ evidence=f"crawled from {endpoint}",
75
+ )
76
+
77
+ resources = [
78
+ {
79
+ "uri": normalize_resource_uri(r.uri),
80
+ "name": r.name,
81
+ "mime_type": r.mime_type,
82
+ "description": r.description,
83
+ }
84
+ for r in result.resources
85
+ ]
86
+ if resources:
87
+ tx.run(
88
+ """
89
+ MATCH (s:MCPServer {name:$name})
90
+ UNWIND $resources AS r
91
+ MERGE (res:Resource {uri:r.uri})
92
+ SET res.name=r.name, res.mime_type=r.mime_type, res.description=r.description
93
+ MERGE (s)-[p:PROVIDES]->(res)
94
+ SET p.source='crawled', p.confidence='high', p.evidence=$evidence
95
+ """,
96
+ name=result.server_name,
97
+ resources=resources,
98
+ evidence=f"crawled from {endpoint}",
99
+ )
100
+
101
+ # Reconcile tools this server no longer exposes. Drop the EXPOSES edge always;
102
+ # DETACH DELETE only pure crawl artifacts (no authored governance). Tools that
103
+ # still carry authored CAN_CALL/READS/WRITES/GOVERNED_BY are KEPT — silently
104
+ # deleting them would drop a DENY and create a false ALLOW — and surfaced as a
105
+ # warning to re-run ingest-manifest.
106
+ keys = [t["key"] for t in tools]
107
+ tx.run(
108
+ "MATCH (s:MCPServer {name:$name})-[e:EXPOSES]->(old:Tool) "
109
+ "WHERE NOT old.key IN $keys DELETE e",
110
+ name=result.server_name,
111
+ keys=keys,
112
+ )
113
+ # A tool the server stopped exposing makes NO annotation claims anymore —
114
+ # the node may be kept below (authored governance), but leaving the hints
115
+ # would have the audit queries cite a crawled/medium claim nobody is
116
+ # making (ADR-0006; Codex review of this branch caught it).
117
+ tx.run(
118
+ """
119
+ MATCH (old:Tool {server:$name})
120
+ WHERE NOT old.key IN $keys
121
+ SET old.read_only_hint=null, old.destructive_hint=null,
122
+ old.idempotent_hint=null, old.open_world_hint=null
123
+ """,
124
+ name=result.server_name,
125
+ keys=keys,
126
+ )
127
+ tx.run(
128
+ """
129
+ MATCH (old:Tool {server:$name})
130
+ WHERE NOT old.key IN $keys
131
+ AND NOT EXISTS { MATCH (old)-[:CAN_CALL|READS|WRITES|GOVERNED_BY]-() }
132
+ DETACH DELETE old
133
+ """,
134
+ name=result.server_name,
135
+ keys=keys,
136
+ )
137
+ rec = tx.run(
138
+ "MATCH (old:Tool {server:$name}) WHERE NOT old.key IN $keys "
139
+ "RETURN collect(old.key) AS orphaned",
140
+ name=result.server_name,
141
+ keys=keys,
142
+ ).single()
143
+ orphaned = (rec["orphaned"] if rec else None) or []
144
+
145
+ # PROVIDES: drop edges to resources no longer advertised; keep the (shared,
146
+ # possibly governed) Resource node itself.
147
+ tx.run(
148
+ """
149
+ MATCH (s:MCPServer {name:$name})-[p:PROVIDES]->(old:Resource)
150
+ WHERE NOT old.uri IN $uris
151
+ DELETE p
152
+ """,
153
+ name=result.server_name,
154
+ uris=[r["uri"] for r in resources],
155
+ )
156
+
157
+ # ADR-0004: same transaction as the mutation, so a reader can never see
158
+ # new graph state under an old generation (or vice versa).
159
+ bump_generation(tx)
160
+
161
+ return [
162
+ f"tool {k!r} no longer exposed by {result.server_name!r} but retains authored "
163
+ f"governance (kept so a DENY cannot silently become ALLOW; remove it from the "
164
+ f"manifest and re-ingest — re-ingesting unchanged just re-targets the stale "
165
+ f"tool and emits a DRIFT notice)"
166
+ for k in orphaned
167
+ ]
168
+
169
+
170
+ def load_crawl_result(result: CrawlResult) -> list[str]:
171
+ """Upsert one server's crawl into the graph; return reconciliation warnings."""
172
+ with session() as s:
173
+ return s.execute_write(_merge_crawl, result)
174
+
175
+
176
+ def _retire_unlisted(tx: ManagedTransaction, keep: list[str]) -> tuple[list[str], list[str]]:
177
+ rec = tx.run(
178
+ "MATCH (s:MCPServer) WHERE NOT s.name IN $keep "
179
+ "RETURN collect(s.name) AS retired",
180
+ keep=keep,
181
+ ).single()
182
+ retired = sorted((rec["retired"] if rec else None) or [])
183
+ if not retired:
184
+ # Nothing changed — don't bump the generation (ADR-0004: the token
185
+ # invalidates caches; a no-op reconciliation must leave them valid).
186
+ return [], []
187
+
188
+ # Resources these servers provided: candidates for deletion once the
189
+ # PROVIDES edges drop with the server node below.
190
+ rec = tx.run(
191
+ "MATCH (s:MCPServer)-[:PROVIDES]->(r:Resource) WHERE s.name IN $names "
192
+ "RETURN collect(DISTINCT r.uri) AS uris",
193
+ names=retired,
194
+ ).single()
195
+ resource_uris = (rec["uris"] if rec else None) or []
196
+
197
+ # A retired server makes no annotation claims anymore (ADR-0006) — kept
198
+ # tools must not keep citing crawled/medium self-claims from a server
199
+ # that no longer exists.
200
+ tx.run(
201
+ """
202
+ MATCH (t:Tool) WHERE t.server IN $names
203
+ SET t.read_only_hint=null, t.destructive_hint=null,
204
+ t.idempotent_hint=null, t.open_world_hint=null
205
+ """,
206
+ names=retired,
207
+ )
208
+ # Same keep-rule as per-server reconciliation: tools with authored
209
+ # governance are kept (a silent delete would turn a DENY into a false
210
+ # ALLOW); pure crawl artifacts go.
211
+ tx.run(
212
+ """
213
+ MATCH (t:Tool) WHERE t.server IN $names
214
+ AND NOT EXISTS { MATCH (t)-[:CAN_CALL|READS|WRITES|GOVERNED_BY]-() }
215
+ DETACH DELETE t
216
+ """,
217
+ names=retired,
218
+ )
219
+ rec = tx.run(
220
+ "MATCH (t:Tool) WHERE t.server IN $names RETURN collect(t.key) AS kept",
221
+ names=retired,
222
+ ).single()
223
+ kept = sorted((rec["kept"] if rec else None) or [])
224
+
225
+ # The server node and all its EXPOSES/PROVIDES edges go together — kept
226
+ # tools lose their EXPOSES here and become honestly drifted.
227
+ tx.run(
228
+ "MATCH (s:MCPServer) WHERE s.name IN $names DETACH DELETE s",
229
+ names=retired,
230
+ )
231
+ # ADR-0001 resource rule (same degree-0 filter as ingest Phase 2): a
232
+ # resource nobody provides and nothing authored references is a crawl
233
+ # artifact of a dead server. Shared or governed resources keep edges
234
+ # and never match.
235
+ tx.run(
236
+ """
237
+ MATCH (r:Resource) WHERE r.uri IN $uris
238
+ AND NOT EXISTS { MATCH (r)--() }
239
+ DELETE r
240
+ """,
241
+ uris=resource_uris,
242
+ )
243
+
244
+ bump_generation(tx)
245
+ return retired, [
246
+ f"tool {k!r} kept after its server was retired from servers.yaml — it "
247
+ f"retains authored governance (kept so a DENY cannot silently become "
248
+ f"ALLOW; it is now drifted — remove it from the manifest and re-ingest "
249
+ f"to retire it)"
250
+ for k in kept
251
+ ]
252
+
253
+
254
+ def retire_unlisted_servers(keep: set[str]) -> tuple[list[str], list[str]]:
255
+ """Fleet reconciliation (ADR-0007): retire crawled subgraphs of servers
256
+ no longer in servers.yaml. Returns (retired server names, warnings)."""
257
+ with session() as s:
258
+ return s.execute_write(_retire_unlisted, sorted(keep))
259
+
260
+
261
+ def graph_counts() -> dict[str, int]:
262
+ """Node/edge tallies — used by the idempotency check and the demo.
263
+
264
+ One UNION ALL statement instead of five separate count queries.
265
+ """
266
+ query = """
267
+ MATCH (n:MCPServer) RETURN 'mcpserver' AS name, count(n) AS count
268
+ UNION ALL
269
+ MATCH (n:Tool) RETURN 'tool' AS name, count(n) AS count
270
+ UNION ALL
271
+ MATCH (n:Resource) RETURN 'resource' AS name, count(n) AS count
272
+ UNION ALL
273
+ MATCH ()-[r:EXPOSES]->() RETURN 'exposes' AS name, count(r) AS count
274
+ UNION ALL
275
+ MATCH ()-[r:PROVIDES]->() RETURN 'provides' AS name, count(r) AS count
276
+ """
277
+ names = ("mcpserver", "tool", "resource", "exposes", "provides")
278
+ with session() as s:
279
+ counts = {r["name"]: r["count"] for r in s.run(query)}
280
+ return {name: counts.get(name, 0) for name in names}