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,674 @@
1
+ Metadata-Version: 2.5
2
+ Name: toolgraph
3
+ Version: 0.0.1
4
+ Summary: Graph-native MCP/tool registry, policy compiler, and impact analyzer
5
+ Project-URL: Homepage, https://github.com/memtomem/toolgraph
6
+ Project-URL: Repository, https://github.com/memtomem/toolgraph
7
+ Project-URL: Issues, https://github.com/memtomem/toolgraph/issues
8
+ Author: Toolgraph contributors
9
+ Author-email: "DAPADA Inc." <contact@dapada.co.kr>
10
+ License: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: agents,ai,governance,graph,mcp,policy,security
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: <3.15,>=3.12
22
+ Requires-Dist: cryptography>=50.0.0
23
+ Requires-Dist: idna>=3.18
24
+ Requires-Dist: mcp<2,>=1.28.1
25
+ Requires-Dist: neo4j<7,>=6.2
26
+ Requires-Dist: pydantic-settings>=2.14.2
27
+ Requires-Dist: pydantic<3,>=2.7
28
+ Requires-Dist: pyjwt>=2.13.0
29
+ Requires-Dist: python-dotenv>=1.0
30
+ Requires-Dist: python-multipart>=0.0.31
31
+ Requires-Dist: pyyaml>=6.0
32
+ Requires-Dist: starlette>=1.3.1
33
+ Requires-Dist: typer>=0.27.0
34
+ Provides-Extra: ladybug
35
+ Requires-Dist: ladybug<0.19,>=0.18.2; extra == 'ladybug'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Toolgraph
39
+
40
+ > 🚧 **Alpha** β€” APIs and defaults may change between `0.x` minor releases.
41
+ > Feedback and issue reports are especially welcome:
42
+ > [Issues](https://github.com/memtomem/toolgraph/issues) Β·
43
+ > [Discussions](https://github.com/memtomem/toolgraph/discussions)
44
+ >
45
+ > Toolgraph is an advisory control-plane tool, not a runtime security boundary.
46
+
47
+ A graph-native registry for MCP tools. It crawls MCP servers into a local Ladybug or shared Neo4j graph,
48
+ lets you author governance (who can call what, what touches which resource, which
49
+ resources are under which policy), and answers **explainable reachability
50
+ questions** over the result:
51
+
52
+ - *Which tools can this agent call that touch a resource under a DENY policy?*
53
+ - *Is this agent allowed to call this tool β€” and does the call cross a deny policy?*
54
+ - *If this resource (or policy) changes, which agents and tools are affected?*
55
+
56
+ Each answer comes back with the **exact graph path as evidence**, not a bare boolean,
57
+ and carries per-edge **provenance** (crawled fact vs operator assertion vs inferred,
58
+ with a free-text evidence pointer) so reviewers can verify the claim.
59
+
60
+ > **Advisory, not enforcement.** toolgraph analyzes and explains; it does **not**
61
+ > sit in the traffic path and does not block calls at runtime. Think of it as a
62
+ > risk/impact analyzer and policy compiler for your MCP tool estate. Runtime
63
+ > enforcement consumes its product-neutral policy bundle in a gateway;
64
+ > memtomem-stm is the first reference consumer, not a required dependency.
65
+
66
+ ## Why a graph (and what the alternatives are)
67
+
68
+ Discovery (β€œfind a tool like X”) is well served by vector search. Embedding
69
+ search **alone**, however, cannot give deterministic reachability or
70
+ blast-radius answers β€” `Agent β†’ Tool β†’ Resource β†’ Policy` is a many-to-many
71
+ join across four node types, not a similarity problem.
72
+
73
+ Several structured-metadata approaches *can* answer the same questions if
74
+ given the same edges:
75
+
76
+ - OPA/Rego over a JSON-authored ACL model
77
+ - A relational schema with `(agent, tool, resource, policy)` joins
78
+ - A resource-tagged tool registry with namespace + grant tables
79
+
80
+ toolgraph is one implementation of that model, optimised for: idempotent
81
+ crawl-then-author flows, explainable paths returned as first-class output,
82
+ and exposing the same queries over MCP so an agent can audit the registry
83
+ through the protocol it was crawled from. It does **not** claim "only a
84
+ graph can answer this." It claims the graph form makes the authoring +
85
+ audit ergonomics simple, and that embedding-only registries cannot.
86
+
87
+ Every edge here is either a crawled fact or an operator-authored assertion;
88
+ nothing is LLM-guessed.
89
+
90
+ ## Graph model
91
+
92
+ ```
93
+ (MCPServer)-[:EXPOSES]->(Tool) # crawled (factual)
94
+ (MCPServer)-[:PROVIDES]->(Resource) # crawled (factual)
95
+ (Agent)-[:CAN_CALL]->(Tool) # authored
96
+ (Tool)-[:READS|WRITES]->(Resource) # authored
97
+ (Resource|Tool)-[:GOVERNED_BY]->(Policy) # authored
98
+ ```
99
+
100
+ `Policy{id, effect: ALLOW|DENY, scope, description}`.
101
+
102
+ ## Quickstart
103
+
104
+ New to Toolgraph? Start with the first-time user guides:
105
+ [English](https://github.com/memtomem/toolgraph/blob/main/docs/beginner-guide.md) or
106
+ [Korean](https://github.com/memtomem/toolgraph/blob/main/docs/ko-beginner-guide.md).
107
+
108
+ For the context-engineering and tool-selection roadmap, see
109
+ the [context-engineering report](https://github.com/memtomem/toolgraph/blob/main/docs/context-engineering-tool-selection-report.md).
110
+ Contract-shaping decisions are recorded in the
111
+ [ADRs](https://github.com/memtomem/toolgraph/tree/main/docs/adr).
112
+ Maintainers should follow the [release runbook](https://github.com/memtomem/toolgraph/blob/main/docs/releasing.md).
113
+
114
+ ### Install
115
+
116
+ The recommended local backend is included through the `ladybug` extra:
117
+
118
+ ```bash
119
+ uv tool install "toolgraph[ladybug]"
120
+ # or: python -m pip install "toolgraph[ladybug]"
121
+ ```
122
+
123
+ That puts `toolgraph` on your `PATH`; run `uv tool update-shell` once if `uv`
124
+ says its bin directory is not there yet.
125
+
126
+ You can also inspect the CLI without a persistent install:
127
+
128
+ ```bash
129
+ uvx --from "toolgraph[ladybug]" toolgraph --version
130
+ ```
131
+
132
+ <details>
133
+ <summary>From source</summary>
134
+
135
+ ```bash
136
+ git clone https://github.com/memtomem/toolgraph.git
137
+ cd toolgraph
138
+ uv tool install --from '.[ladybug]' toolgraph
139
+ ```
140
+
141
+ </details>
142
+
143
+ ### First policy bundle
144
+
145
+ Once `toolgraph` is on your `PATH`, no Docker, Node.js or clone is required:
146
+
147
+ ```bash
148
+ toolgraph example init toolgraph-quickstart
149
+ cd toolgraph-quickstart
150
+ toolgraph init
151
+ toolgraph crawl --servers servers.yaml
152
+ toolgraph ingest-manifest --governance governance.yaml --strict-drift
153
+ toolgraph eligible-tools vibe-coder \
154
+ policy-gateway::read_note policy-gateway::publish_note --profile review
155
+ toolgraph selection-explain vibe-coder policy-gateway::publish_note
156
+ toolgraph policy compile --agent vibe-coder --profile review \
157
+ --output .toolgraph/policy-bundle.json
158
+ ```
159
+
160
+ Ladybug is the local single-process backend. Toolgraph commands own the DB
161
+ sequentially; gateways read only the generated JSON bundle. Use Neo4j for a
162
+ shared service or multiple concurrent processes. The beginner guides explain
163
+ the decision output and the memtomem-stm handoff.
164
+
165
+ For contributors and larger shared/fleet demos, clone the repository and run:
166
+
167
+ ```bash
168
+ uv sync --group dev --extra ladybug
169
+ docker compose up -d --wait
170
+ cp .env.example .env
171
+ bash scripts/demo.sh
172
+ bash scripts/demo-public.sh
173
+ ```
174
+
175
+ The compose file is development-only, uses a known password, and binds Neo4j
176
+ to loopback. It is not an authenticated production deployment. The demo
177
+ scripts themselves use disposable Ladybug databases and never reset your
178
+ configured graph.
179
+
180
+ The cross-server demo is the headline: `ci-bot` has no filesystem read grant,
181
+ yet `unsafe-tools ci-bot` flags it β€” because `git_show` on a *different* server
182
+ reaches the same governed secret. The graph form makes the cross-server path
183
+ explicit without per-server bookkeeping; the same authored edges in a
184
+ relational or OPA model would catch it too, but operators would have to
185
+ write the join themselves.
186
+
187
+ The same wedge appears for **proxy/surfacing servers**. `examples/servers-gate.yaml`
188
+ crawls memtomem + memtomem-stm (a docs proxy whose `surfacing` engine injects
189
+ results from the operator's memtomem LTM into every proxied response β€” see
190
+ `memtomem_stm/proxy/manager.py::ProxyManager._apply_surfacing`).
191
+ `examples/governance-gate.yaml` flags
192
+ an agent whose only grants are on proxied docs tools (`langchain__*`,
193
+ `langfuse__*`) β€” because surfacing makes every such call indirectly read the
194
+ operator's personal LTM namespace, which their descriptions never mention.
195
+ Run with:
196
+
197
+ ```bash
198
+ uv run toolgraph crawl --servers examples/servers-gate.yaml
199
+ uv run toolgraph ingest-manifest --governance examples/governance-gate.yaml
200
+ uv run toolgraph check-access helper "memtomem-stm::langchain__search_docs_by_lang_chain"
201
+ ```
202
+
203
+ Or step by step:
204
+
205
+ ```bash
206
+ uv run toolgraph check # verify Neo4j connectivity
207
+ uv run toolgraph init-schema # uniqueness constraints
208
+ uv run toolgraph crawl --servers examples/servers.yaml # crawl -> load factual graph
209
+ uv run toolgraph ingest-manifest --governance examples/governance.yaml
210
+ # refs to tools no server currently exposes emit a non-fatal DRIFT notice;
211
+ # add --strict-drift to reject the manifest instead (CI gating, ADR-0002)
212
+
213
+ # positive-reachability queries
214
+ uv run toolgraph unsafe-tools public-bot # violations only (default)
215
+ uv run toolgraph unsafe-tools public-bot --all # + operator-declared exceptions
216
+ uv run toolgraph check-access public-bot read_file
217
+ uv run toolgraph blast-radius "file:///private/secrets.env"
218
+
219
+ # negative-truth / drift queries β€” what the graph DOESN'T say but should
220
+ uv run toolgraph unmapped-tools # granted tools w/ no READS/WRITES yet
221
+ uv run toolgraph orphan-policies # policies no agent currently reaches
222
+ uv run toolgraph unbacked-edges # READS/WRITES/GOVERNED_BY w/ no evidence
223
+ uv run toolgraph unbacked-edges --include-grants # also audit CAN_CALL grants
224
+ uv run toolgraph drift # tools w/ governance but no live EXPOSES
225
+ uv run toolgraph destructive-unsafeguarded # destructive-hinted tools w/ no policy path
226
+ uv run toolgraph annotation-contradictions # authored WRITES vs readOnlyHint claims
227
+ uv run toolgraph audit-report # whole-graph operational audit summary
228
+ uv run toolgraph audit-report --format markdown # human-readable audit report
229
+
230
+ # selector surface (ADR-0005) β€” batch, deterministic, no relevance scoring
231
+ uv run toolgraph rank-features planner git_status read_file export_data
232
+ uv run toolgraph eligible-tools planner git_status read_file --profile strict
233
+ uv run toolgraph selection-explain planner git_status
234
+
235
+ # advisory run artifact for syncmill/tracegraph correlation
236
+ uv run toolgraph preflight planner git_status read_file \
237
+ --profile review --run-id "$RUN_ID" --out preflight.json
238
+
239
+ # body-free orchestration topology + principal/tool-surface preflight
240
+ uv run toolgraph control-preflight control-plan.json \
241
+ --profile review --out control-preflight.json
242
+
243
+ # portable control-plane artifact for memtomem-stm or another MCP gateway
244
+ uv run toolgraph policy compile --agent planner --profile strict \
245
+ --output policy-bundle.json
246
+
247
+ # human review of Tracegraph T3 findings (no Neo4j or policy mutation)
248
+ uv run toolgraph review-candidates list candidates.json
249
+ uv run toolgraph review-candidates annotate candidates.json CANDIDATE_ID \
250
+ --status accepted --reviewer operator --note "manual policy review approved"
251
+
252
+ # accepted evidence -> current-policy work plan (still no policy mutation)
253
+ uv run toolgraph policy review-plan candidates.json --agent planner \
254
+ --profile review --output policy-review-plan.json
255
+ ```
256
+
257
+ `preflight` wraps the same deterministic selector rules and stamps the result
258
+ with a bracketed `graph_generation`. It remains advisory: rejected candidates
259
+ produce `decision: advisory_warn` and exit 0. Unknown agents produce
260
+ `unresolved_identity`, never a false allow. Resource URIs in evidence are
261
+ scrubbed of userinfo and query strings before the artifact is written. Pass
262
+ `--features` only when the consumer needs the full selector feature rows.
263
+ With `--out` the artifact is written privately and atomically, and the command
264
+ prints a result envelope carrying `artifact_digest` β€” the SHA-256 of the exact
265
+ file bytes in the ecosystem's `sha256:<hex>` form, so a run report can record
266
+ the value as-is and a consumer can verify it with `sha256sum`. Digests cover
267
+ bytes, not parsed JSON: a consumer that re-serializes the artifact is hashing a
268
+ different document. Without `--out` the artifact itself goes to stdout. SyncMill P3.1 may enforce this evidence before orchestration, but the
269
+ producer artifact and Toolgraph exit-code contract remain advisory (ADR-0008).
270
+
271
+ `control-preflight` accepts a versioned, body-free upper-bound control graph.
272
+ It checks one start and end, reachability, acyclicity, terminal paths, bounded
273
+ model fan-out, and aggregate call/parallel limits. It then evaluates every
274
+ declared principal and qualified candidate tool inside one collision-safe
275
+ `graph_state` bracket. The canonical private result is still advisory and
276
+ exits successfully for policy or structural warnings; an orchestrator such as
277
+ SyncMill decides whether those warnings block a run. Prompts, outputs, patches,
278
+ credentials, commands, memory bodies, and filesystem paths do not belong in
279
+ either exchange artifact. Toolgraph does not execute, persist, resume, or
280
+ observe the declared workflow (ADR-0013).
281
+
282
+ `policy compile` evaluates every currently exposed qualified tool, fingerprints
283
+ the crawled tool contract, and writes canonical JSON with private permissions
284
+ and atomic replacement. Each tool appears exactly once in the bundle; a
285
+ duplicated catalog row fails compilation instead of shipping. The bundle
286
+ carries a collision-safe
287
+ `graph_state: {instance_id, generation}` plus governance and catalog digests.
288
+ Toolgraph still does not claim that it blocked a call: a gateway consumes the
289
+ bundle, chooses review or strict behavior, and records the enforcement action
290
+ (ADR-0010). Re-run `ingest-manifest` before compilation when upgrading a graph
291
+ that predates the governance-digest field.
292
+
293
+ `review-candidates` is the G3 human-review boundary (ADR-0009). It consumes
294
+ Tracegraph's body-free schema-v1 report and projects only `run_id`, versioned
295
+ pattern identity, qualified `tool_key`, and the analyzed artifact digest.
296
+ Unknown additive v1 fields are discarded; unknown major versions fail closed.
297
+ The original report is never edited. `annotate` appends `open` / `accepted` /
298
+ `dismissed` events to `<REPORT>.toolgraph-review.json`, bound to the SHA-256 of
299
+ the exact report bytes and written under a local lock with atomic replacement.
300
+ The sidecar path is resolved before load, lock, and replacement so symlink
301
+ aliases cannot split history. Successful replacement fsyncs both the file and
302
+ its parent directory where the platform supports it.
303
+ Local review notes may cite paths but reject credential-shaped content.
304
+ If a genuine parent-directory fsync error occurs after replacement, the event
305
+ remains successful and visible while a durability warning is written to stderr;
306
+ retrying would append a duplicate event.
307
+
308
+ Candidate ids use the same exact-tuple UUIDv5 as SyncMill's merged
309
+ `board import-review-candidates` consumer (SyncMill PR #57), so the two human
310
+ review surfaces can be correlated without sharing storage. Their states remain
311
+ independent: Toolgraph `accepted` is only a policy-review disposition. It does
312
+ not complete a SyncMill board item, modify governance, change selector output,
313
+ or increment `graph_generation`.
314
+
315
+ `policy review-plan` is the explicit next step after acceptance (ADR-0012).
316
+ It selects only currently accepted candidates, evaluates their qualified tool
317
+ keys against one agent/profile, binds the result to the current graph instance
318
+ and generation, and writes a private atomic work-plan artifact. It deliberately
319
+ sets `decision_mode: "human_required"` and `automatic_change: false`: an
320
+ operator still edits the governance manifest, runs `ingest-manifest`, reviews
321
+ the resulting decision/blast radius, and then runs `policy compile`. Trace
322
+ evidence never grants, denies, or creates an exception by itself.
323
+
324
+ ### Exit codes
325
+
326
+ Per [ADR-0003](https://github.com/memtomem/toolgraph/blob/main/docs/adr/0003-cli-output-and-exit-code-contract.md) this table
327
+ is a contract: query commands reserve stdout for their JSON (diagnostics go
328
+ to stderr) and exit 0 by default β€” gating is opt-in.
329
+
330
+ | Command | Exit code |
331
+ | --- | --- |
332
+ | query commands (default) | 0 always β€” advisory, even on DENY/violations |
333
+ | `unsafe-tools` / `rank-features` / `eligible-tools` / `selection-explain` (unknown agent) | 1 β€” `AGENT_NOT_FOUND` printed to stderr |
334
+ | `preflight` | 0 for advisory allow, warn, and unresolved identity; operational/argument errors are non-zero |
335
+ | `policy compile` | 0 after atomic bundle replacement Β· 1 on unresolved identity/state or I/O failure |
336
+ | `review-candidates list/annotate` | 0 on valid JSON output Β· 1 on invalid/stale artifacts or I/O failure Β· 2 on invalid CLI arguments |
337
+ | `ingest-manifest` (manifest REJECTED) | 1 |
338
+ | `crawl` (any server failed) | 1 |
339
+ | `check-access --fail-on-deny` | 1 on `DENY` with `all_authorized: false` Β· 2 on `AGENT_NOT_FOUND` / `TOOL_NOT_FOUND` / `AMBIGUOUS_TOOL` Β· 0 otherwise (`ALLOW`, `NOT_GRANTED`, authorized-only `DENY`) |
340
+ | `unsafe-tools --fail-on-violation` | 1 when any `violation` row remains in the printed output (with or without `--all`) |
341
+
342
+ A typo'd name exits 2 under `--fail-on-deny` so it can't pass a CI gate as a
343
+ clean run.
344
+
345
+ Note for CI scripts: exit 2 is overloaded. Click uses 2 for usage errors (a
346
+ bad flag or parameter), and `--fail-on-deny` uses 2 for not-found/ambiguous
347
+ verdicts. The two are indistinguishable by exit code alone β€” a gate that must
348
+ tell them apart should also check stderr (verdict trips print the JSON verdict
349
+ on stdout first; usage errors print click's usage text).
350
+
351
+ ### Distinguishing what the graph doesn't know
352
+
353
+ - `check-access` verdicts include `AGENT_NOT_FOUND` (the agent string matches
354
+ no Agent node β€” typo or deleted agent) and `TOOL_NOT_FOUND`. The two are
355
+ not collapsed into `NOT_GRANTED`, so an operator iterating on names can
356
+ tell which case they're in. Every response carries `agent_found: bool`
357
+ and `found` for the tool β€” `True`/`False` when tool resolution ran, and
358
+ `null` on the `AGENT_NOT_FOUND` branch (the tool was not looked up).
359
+ Callers should use `result.get("found")` so the three-state shape doesn't
360
+ trip indexing.
361
+ - When `verdict: "DENY"`, the response also carries
362
+ `all_authorized: bool` β€” `true` when every `deny_evidence` row is
363
+ `authorized_but_governed` (an operator-declared exception). Lets a caller
364
+ detect "DENY only because of by-design grants" without aggregating per-row
365
+ classifications.
366
+ - `blast-radius` returns `found: false` when the node argument matches no
367
+ Resource or Policy. A typo'd URI is no longer indistinguishable from
368
+ `impacted: []`. `blast-radius` accepts a URI-shaped argument as either a
369
+ resource URI or a policy id β€” if the lexical URI pattern matches no
370
+ Resource node, it retries against Policy ids before declaring not-found
371
+ (so a policy id like `urn:secret` or `mailto:ops` works as a node).
372
+ - `unsafe-tools` (CLI) and the `unsafe_callable_tools` MCP tool both
373
+ default to `classification='violation'` only; pass `--all` (CLI) or
374
+ `include_authorized: true` (MCP) to see operator-declared exceptions
375
+ too. Both surfaces also carry `agent_found: bool` so a typo'd agent
376
+ doesn't look like an agent with zero unsafe tools.
377
+
378
+ ### Result classification
379
+
380
+ `unsafe-tools` and `check-access` results carry a `classification`:
381
+
382
+ - `violation` β€” the agent reaches a DENY-governed resource and the operator
383
+ has not declared the reach intentional.
384
+ - `authorized_but_governed` β€” operator declared `expected_exceptions` in the
385
+ manifest covering this (agent, tool, resource?, policy); the row carries
386
+ the operator's stated `exception_reason`.
387
+
388
+ This replaces the bare "unsafe" framing: review tooling can filter to
389
+ `violation` rows and stop drowning reviewers in by-design grants.
390
+
391
+ ### Provenance on every edge
392
+
393
+ Each edge β€” crawled or authored β€” carries `source` (`crawled` |
394
+ `operator_asserted` | `inferred`), `confidence` (`high` | `medium` | `low`),
395
+ and `evidence` (free-text pointer: URL, ticket id, commit SHA, source
396
+ location β€” prefer symbol anchors like `path/to/file.py::Class.method` over
397
+ `file:line`, which rots as the cited source evolves).
398
+ Queries surface this on every result so a reviewer can see whether a DENY
399
+ verdict rests on a crawled fact or an unverified assertion:
400
+
401
+ ```yaml
402
+ data_access:
403
+ - tool: "memtomem-stm::langchain__search_docs_by_lang_chain"
404
+ resource: "memory:claude:toolgraph"
405
+ mode: READS
406
+ provenance:
407
+ source: inferred
408
+ confidence: high
409
+ evidence: "memtomem_stm/proxy/manager.py::ProxyManager._apply_surfacing -> memtomem_stm/surfacing/engine.py::SurfacingEngine.surface"
410
+ ```
411
+
412
+ `governed_by` bindings carry their own provenance β€” *why* this resource is
413
+ under this policy, which is often a different citation from the data-flow
414
+ edge. Bare-string entries are still accepted (no evidence, equivalent to
415
+ the old shape); add an object item to cite the binding's source:
416
+
417
+ ```yaml
418
+ governed_by:
419
+ "memory:claude:toolgraph":
420
+ - policy: operator-ltm-confidentiality
421
+ provenance:
422
+ source: operator_asserted
423
+ evidence: "~/.memtomem/config.json (namespace.rules)"
424
+ "file:///srv/app/.env": [secrets-deny] # bare string β€” no evidence
425
+ ```
426
+
427
+ When a DENY fires via a resource path, the result row includes both
428
+ `provenance` (the data-flow edge that put the row in front of you) and
429
+ `policy_provenance` (the binding evidence, attached only when the author
430
+ cited one).
431
+
432
+ ### Tool annotations are self-claims, not facts
433
+
434
+ The crawler stores MCP tool annotations (`readOnlyHint`, `destructiveHint`,
435
+ `idempotentHint`, `openWorldHint`) as Tool-node properties on the same crawl
436
+ pass that owns the node, and clears any hint the server stops sending
437
+ ([ADR-0006](https://github.com/memtomem/toolgraph/blob/main/docs/adr/0006-crawl-tool-annotations-as-self-claims.md)).
438
+
439
+ Their provenance is `crawled` / **`medium`** β€” deliberately one tier below
440
+ EXPOSES/PROVIDES (`high`). "The server advertised this tool" is
441
+ protocol-observable; "this tool is read-only" is the server's *own unverified
442
+ claim about itself*, and a lying or sloppy server can hint anything. Letting
443
+ hints launder into the same confidence tier as protocol facts would defeat the
444
+ provenance model.
445
+
446
+ Annotations never auto-create READS/WRITES/GOVERNED_BY edges β€” they only feed
447
+ two audit queries that join self-claims against authored truth:
448
+
449
+ - `destructive-unsafeguarded` β€” tools hinting destructive with no
450
+ GOVERNED_BY path at all (direct or via any resource they touch). An
451
+ authoring priority queue at zero authoring cost, not a verdict.
452
+ - `annotation-contradictions` β€” authored WRITES edges on tools hinting
453
+ `readOnlyHint: true`. One side is wrong; each row cites both provenances
454
+ (the hint's crawl pass, the authored edge's evidence pointer) and the
455
+ operator picks the stronger evidence.
456
+
457
+ ## Selector surface (deterministic, ADR-0005)
458
+
459
+ toolgraph can serve tool selection as a context-engineering layer: shrink a
460
+ candidate catalog deterministically *before* any relevance ranker sees it
461
+ ([ADR-0005](https://github.com/memtomem/toolgraph/blob/main/docs/adr/0005-selector-surface-boundary.md), background in
462
+ [the report](https://github.com/memtomem/toolgraph/blob/main/docs/context-engineering-tool-selection-report.md)). Three
463
+ batch-first entry points (CLI + MCP) run a fixed number of queries
464
+ regardless of candidate count β€” per-candidate `check-access` calls scale
465
+ linearly, which is unacceptable on the online path:
466
+
467
+ - `rank-features AGENT CANDIDATES…` β€” per-candidate facts in input order:
468
+ resolution (`found` / `ambiguous` / `tool_key`), grant, verdict, DENY
469
+ classification + evidence paths, drift, mapping, evidence coverage, the
470
+ four annotation self-claims, and a **rule-based** `risk_score` from this
471
+ fixed table (first match wins): `1.0` DENY violation Β· `0.8` drifted Β·
472
+ `0.6` unmapped Β· `0.4` unbacked load-bearing edge Β· `0.2`
473
+ authorized_but_governed Β· `0.0` otherwise.
474
+ - `eligible-tools AGENT CANDIDATES… --profile strict` β€” hard filter with
475
+ reject reasons and policy-evidence paths. Profiles are named rule sets,
476
+ not learned thresholds:
477
+
478
+ | Profile | Use | Rejects |
479
+ | --- | --- | --- |
480
+ | `strict` (default) | production agents | everything but a clean, fully-authored ALLOW: not-found / ambiguous / not-granted / any DENY / drifted / unmapped |
481
+ | `review` | human-in-the-loop | not-found / ambiguous / not-granted / DENY violations / drifted (operator exceptions and unmapped pass) |
482
+ | `explore` | offline eval / sandbox | not-found / ambiguous / not-granted / drifted only |
483
+
484
+ - `selection-explain AGENT TOOL` β€” compact human-readable reasons for one
485
+ decision, for operators and end users.
486
+
487
+ Hard boundary: no relevance scoring, no learning, ever β€” those belong to the
488
+ consumer. A learned model may rerank the eligible list but may **never
489
+ override a hard reject** (`NOT_GRANTED`, `violation`, drifted). Ambiguous
490
+ bare names are never auto-resolved β€” the consumer must re-submit the
491
+ server-qualified key. An unknown agent returns `agent_found: false`
492
+ (CLI: `AGENT_NOT_FOUND` on stderr, exit 1): selection should abort, because
493
+ a typo'd agent is a context-construction error, not an empty catalog.
494
+
495
+ MCP responses carry `graph_generation` (ADR-0004) so consumers can cache
496
+ features per graph state with one integer comparison.
497
+
498
+ If the graph backend is temporarily unavailable, every MCP tool returns
499
+ `isError: true` with a typed `structuredContent` envelope instead of exposing
500
+ driver-specific text:
501
+
502
+ ```json
503
+ {
504
+ "error_kind": "backend_unavailable",
505
+ "retryable": true,
506
+ "message": "Toolgraph backend is temporarily unavailable; retry later."
507
+ }
508
+ ```
509
+
510
+ Only this exact discriminator is an availability signal. Validation,
511
+ configuration, query-contract, and unexpected failures keep the normal MCP
512
+ error behavior and should fail loud. Successful CLI output and Python result
513
+ shapes are unchanged; only the exception type surfaced on an outage differs
514
+ (see below).
515
+
516
+ Outages are typed once at the backend seam β€” `driver.session()` and
517
+ `verify_connectivity()` raise `BackendUnavailableError` β€” so the server layer
518
+ never inspects driver-specific exception types. `retryable` is read from the
519
+ annotations the server actually advertises for that tool, rather than assumed:
520
+ every current tool is a pure graph read. Anything else fails closed to
521
+ `retryable: false` β€” a tool that does not advertise `readOnlyHint`, an unknown
522
+ tool, and any case where the annotations cannot be read at all. Treat
523
+ `retryable: false` as "do not assume a retry is safe", not as proof that the
524
+ operation writes.
525
+
526
+ **API change for embedders.** Because outages are typed at that seam, code
527
+ calling `driver.session()` or `verify_connectivity()` directly now sees
528
+ `BackendUnavailableError` where it previously saw `neo4j.exceptions`
529
+ `ServiceUnavailable`, `SessionExpired`, `ConnectionAcquisitionTimeoutError`, or
530
+ `DatabaseUnavailable`. Catch `BackendUnavailableError` (or its
531
+ `BackendLockedError` subclass) instead; the original driver exception is
532
+ preserved as `__cause__`. Configuration, authentication, and Cypher/client
533
+ errors are untouched, as is the still-raw `get_driver()` escape hatch. Note
534
+ that `is_backend_unavailable()` narrowed with it: it classifies only
535
+ `BackendUnavailableError`, so exceptions raised straight out of `get_driver()`
536
+ no longer answer yes. Route retry-sensitive calls through `driver.session()`.
537
+
538
+ ### Wiring the shipped consumer (memtomem-stm)
539
+
540
+ The first consumer of this surface is the memtomem-stm proxy
541
+ (memtomem-stm#465): at startup it consults `eligible_tools` +
542
+ `rank_features` over MCP stdio (`toolgraph serve`) and feeds the verdicts
543
+ into its tool-exposure hard filter. The gate exhibit carries the consumer's
544
+ default `agent_id` (`stm-proxy`) as a wiring fixture, so the consult path
545
+ is demoable against the exhibit graph:
546
+
547
+ ```bash
548
+ uv run toolgraph eligible-tools stm-proxy \
549
+ "memtomem-stm::langchain__search_docs_by_lang_chain" --profile strict
550
+ # -> rejected: DENY_GOVERNED (fail closed β€” the consumer's production default)
551
+ uv run toolgraph eligible-tools stm-proxy \
552
+ "memtomem-stm::langchain__search_docs_by_lang_chain" --profile review
553
+ # -> eligible (the operator-declared exception passes under review)
554
+ ```
555
+
556
+ Live wiring differs from the exhibit in one important way: the proxy builds
557
+ candidate refs as `"<upstream-connection-name>::<raw-tool-name>"` from its
558
+ own upstream config, while the exhibit crawls the proxy itself (server
559
+ `memtomem-stm`, prefixed tool names like `langchain__…`). For a live
560
+ deployment, crawl the proxy's *upstreams* directly so graph server names
561
+ match the STM connection keys β€” or map them in the consumer's config:
562
+
563
+ ```json
564
+ "toolgraph": {
565
+ "enabled": true,
566
+ "agent_id": "stm-proxy",
567
+ "query_profile": "strict",
568
+ "server_name_map": { "docs-langchain": "langchain-docs" }
569
+ }
570
+ ```
571
+
572
+ (`"docs-langchain"` is the STM upstream connection key; `"langchain-docs"`
573
+ is the name that upstream was crawled under in this graph.) Refs that don't
574
+ resolve come back `TOOL_NOT_FOUND` and are handled by the consumer's
575
+ `on_tool_not_found` knob β€” if every candidate is rejected as not-found, the
576
+ name mapping (not governance) is what's wrong.
577
+
578
+ ## toolgraph as an MCP server
579
+
580
+ toolgraph exposes its governance and audit queries as MCP tools, so an agent
581
+ can ask the registry about itself over the same protocol toolgraph crawls:
582
+
583
+ ```bash
584
+ uv run toolgraph serve # stdio
585
+ uv run toolgraph serve --http # streamable-http
586
+ ```
587
+
588
+ Tools: `check_access`, `unsafe_callable_tools`, `blast_radius`,
589
+ `unmapped_tools`, `orphan_policies`, `unbacked_edges`, `drifted_tools`,
590
+ `destructive_unsafeguarded`, `annotation_contradictions`, `audit_report`,
591
+ `rank_features`, `eligible_tools`, `selection_explain`.
592
+
593
+ All thirteen are pure graph reads and advertise `readOnlyHint` /
594
+ `idempotentHint`.
595
+
596
+ ## Inputs
597
+
598
+ - `servers.yaml` β€” MCP servers to crawl (stdio `command`/`args`, or http `url`).
599
+ - `governance.yaml` β€” authored agents, policies, `CAN_CALL` grants, `READS/WRITES`
600
+ data access, `GOVERNED_BY` mappings, and `expected_exceptions` for intentional
601
+ DENY-zone reach. Per-edge `provenance` is optional everywhere. See `examples/`.
602
+
603
+ Two reference exhibits:
604
+
605
+ - `examples/servers-gate.yaml` + `governance-gate.yaml` β€” the 2-server
606
+ wedge demo (memtomem + memtomem-stm). Proves toolgraph composes
607
+ cross-server reach a vector registry of tool descriptions cannot.
608
+ - `examples/servers-fleet.yaml` + `governance-fleet.yaml` β€” a 7-server
609
+ fleet (the two above + filesystem, fetch, memory, sequential-thinking,
610
+ time) with minimal but real governance. Validates that per-server
611
+ authoring is minute-scale, not hour-scale. (Caveat: the original
612
+ single-pass took ~48s because the author had already read the
613
+ surfacing-engine source for the gate exhibit; an unfamiliar operator on
614
+ unfamiliar servers should plan for 2–5 minutes per server.)
615
+
616
+ ## Layout
617
+
618
+ ```
619
+ toolgraph/
620
+ crawler/ connect to MCP servers (stdio + streamable-http), enumerate tools/resources
621
+ graph/ Neo4j driver, schema/constraints, idempotent loader, governance/audit queries
622
+ manifest/ parse + ingest authored governance (idempotent)
623
+ server/ FastMCP server exposing the queries as MCP tools
624
+ cli.py crawl / ingest-manifest / check-access / unsafe-tools / blast-radius /
625
+ unmapped-tools / orphan-policies / unbacked-edges / drift /
626
+ destructive-unsafeguarded / annotation-contradictions /
627
+ rank-features / eligible-tools / selection-explain / serve / reset
628
+ ```
629
+
630
+ ## Tests
631
+
632
+ ```bash
633
+ uv run pytest # uses a real Neo4j via testcontainers (no Cypher mocking)
634
+ ```
635
+
636
+ ## Scope (MVP)
637
+
638
+ This is a deliberately thin, thesis-validating MVP. **In:** factual crawl +
639
+ authored governance + three positive-reachability queries + four
640
+ negative-truth/drift queries + classification + per-edge provenance
641
+ (including per-`governed_by`-binding) + exception annotations + `found`
642
+ flags on `check-access` and `blast-radius` so unknown nodes are
643
+ distinguishable from empty results.
644
+
645
+ Idempotency is fully declarative: `ingest-manifest` re-applies authored
646
+ edges (CAN_CALL/READS/WRITES/GOVERNED_BY) AND prunes Agent + Policy nodes
647
+ the manifest no longer mentions, so renaming a policy in YAML does not
648
+ leave the old Policy node behind. Stale `prov_*` properties on Policy
649
+ nodes from earlier shapes are also cleaned at re-ingest.
650
+
651
+ Expected-exception validation enforces a full reachability invariant β€”
652
+ the operator can't declare an exception that fails to classify any row
653
+ (typo'd agent, no CAN_CALL grant, resource not governed by the policy,
654
+ or a wildcard exception with no tool→policy path are all caught at
655
+ ingest with a clear warning).
656
+
657
+ **Still deferred** (no plan to start until use proves them out):
658
+ LLM-inferred dependency/version edges, vector search, an official-registry
659
+ importer, and runtime enforcement. toolgraph remains an analyzer; the
660
+ runtime-enforcement wedge belongs to gateways like Kong.
661
+
662
+ ## Contributing, security, and license
663
+
664
+ - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) β€” development setup,
665
+ pull-request conventions, and the Contributor License Agreement.
666
+ - **CLA:** [CLA.md](CLA.md). Signing is one-time per GitHub account per
667
+ repository; the workflow comments on your first pull request with
668
+ instructions.
669
+ - **Code of conduct:** [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) β€” applies to
670
+ issues, pull requests and discussions.
671
+ - **Security:** [SECURITY.md](SECURITY.md) β€” please use private vulnerability
672
+ reporting rather than a public issue.
673
+ - **License:** Apache License 2.0 β€” see [LICENSE](LICENSE).
674
+ Copyright 2025-2026 DAPADA Inc. and memtomem contributors.