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.
- toolgraph/__init__.py +3 -0
- toolgraph/artifact_safety.py +97 -0
- toolgraph/artifacts.py +122 -0
- toolgraph/cli.py +951 -0
- toolgraph/config.py +106 -0
- toolgraph/control_plan.py +494 -0
- toolgraph/crawler/__init__.py +0 -0
- toolgraph/crawler/client.py +120 -0
- toolgraph/crawler/crawl.py +103 -0
- toolgraph/crawler/transports.py +60 -0
- toolgraph/graph/__init__.py +0 -0
- toolgraph/graph/driver.py +275 -0
- toolgraph/graph/loader.py +280 -0
- toolgraph/graph/queries.py +984 -0
- toolgraph/graph/schema.py +166 -0
- toolgraph/graph/selector.py +423 -0
- toolgraph/graph/store.py +57 -0
- toolgraph/manifest/__init__.py +0 -0
- toolgraph/manifest/ingest.py +542 -0
- toolgraph/manifest/parser.py +26 -0
- toolgraph/models.py +192 -0
- toolgraph/policy_bundle.py +212 -0
- toolgraph/policy_review.py +97 -0
- toolgraph/preflight.py +77 -0
- toolgraph/quickstart/governance.yaml +28 -0
- toolgraph/quickstart/policy_gateway_server.py +31 -0
- toolgraph/quickstart/servers.yaml +5 -0
- toolgraph/redaction.py +82 -0
- toolgraph/review_candidates.py +579 -0
- toolgraph/server/__init__.py +0 -0
- toolgraph/server/app.py +306 -0
- toolgraph/uris.py +64 -0
- toolgraph/yaml_loader.py +68 -0
- toolgraph-0.0.1.dist-info/METADATA +674 -0
- toolgraph-0.0.1.dist-info/RECORD +38 -0
- toolgraph-0.0.1.dist-info/WHEEL +4 -0
- toolgraph-0.0.1.dist-info/entry_points.txt +2 -0
- toolgraph-0.0.1.dist-info/licenses/LICENSE +191 -0
|
@@ -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}
|