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,306 @@
1
+ """toolgraph as an MCP server: agents query the registry over the same protocol
2
+ it crawls. Thin wrappers over graph.queries — all logic lives there.
3
+
4
+ Every response carries ``graph_generation`` (ADR-0004) so callers can cache
5
+ graph-derived features and pin telemetry to a replayable graph state. The
6
+ stamp lives HERE, not in the query layer — CLI output stays unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ import logging
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+ from mcp.server.fastmcp.exceptions import ToolError
17
+ from mcp.types import CallToolResult, ContentBlock, TextContent, ToolAnnotations
18
+
19
+ from toolgraph.graph import queries, selector
20
+ from toolgraph.graph.driver import is_backend_unavailable
21
+ from toolgraph.redaction import redact_text
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _BACKEND_UNAVAILABLE_MESSAGE = (
27
+ "Toolgraph backend is temporarily unavailable; retry later."
28
+ )
29
+
30
+
31
+ class _ToolgraphMCP(FastMCP):
32
+ """FastMCP server with a typed envelope for backend availability only."""
33
+
34
+ def read_only_tool(self, name: str | None = None, **kwargs: Any):
35
+ """Register a pure-read graph tool: safe to retry, no added side effect.
36
+
37
+ Each tool gets its own annotations object, so a later per-tool edit
38
+ cannot leak across every registration.
39
+ """
40
+ if "annotations" in kwargs:
41
+ raise TypeError("read_only_tool owns 'annotations'; use tool() instead")
42
+
43
+ def decorator(fn):
44
+ return self.tool(
45
+ name=name,
46
+ annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True),
47
+ **kwargs,
48
+ )(fn)
49
+
50
+ return decorator
51
+
52
+ async def _is_retryable(self, name: str) -> bool:
53
+ """Whether a failed call to *name* is safe to repeat.
54
+
55
+ Read from the annotations the server actually advertises, so there is
56
+ exactly one source of truth. A parallel registry could disagree with
57
+ the live registration — FastMCP keeps the *first* tool on a duplicate
58
+ name, so a write tool could otherwise inherit a later read-only claim.
59
+
60
+ Every failure path answers "no": an unknown tool, an unannotated tool,
61
+ and a lookup that raises. This runs while an outage is already being
62
+ reported, so a lookup fault must not escape and replace the typed
63
+ envelope with an unstructured error — the exact failure this envelope
64
+ exists to prevent. Under-claiming retry safety is the safe direction,
65
+ but it is still a degraded answer, so log it rather than swallow it.
66
+
67
+ ``BaseException`` is deliberately not caught: cancellation and shutdown
68
+ must propagate rather than be absorbed into a response.
69
+ """
70
+ try:
71
+ for tool in await self.list_tools():
72
+ if tool.name == name:
73
+ return bool(tool.annotations and tool.annotations.readOnlyHint)
74
+ except Exception:
75
+ logger.warning(
76
+ "could not read retry safety for tool %r; reporting not retryable",
77
+ name,
78
+ exc_info=True,
79
+ )
80
+ return False
81
+
82
+ async def call_tool(
83
+ self, name: str, arguments: dict[str, Any]
84
+ ) -> Sequence[ContentBlock] | dict[str, Any] | CallToolResult:
85
+ try:
86
+ return await super().call_tool(name, arguments)
87
+ except ToolError as exc:
88
+ if not is_backend_unavailable(exc):
89
+ # The seam types every outage it covers, so reaching here means
90
+ # either a contract error or a driver exception that never
91
+ # passed the seam. FastMCP puts this message on the wire
92
+ # verbatim, and driver text can carry a connection URI, so it
93
+ # is scrubbed before it leaves. Ordinary contract text is
94
+ # unaffected; the cause is kept for server-side logs.
95
+ raise ToolError(redact_text(exc)) from exc
96
+ payload: dict[str, Any] = {
97
+ "error_kind": "backend_unavailable",
98
+ "retryable": await self._is_retryable(name),
99
+ "message": _BACKEND_UNAVAILABLE_MESSAGE,
100
+ }
101
+ return CallToolResult(
102
+ content=[TextContent(type="text", text=_BACKEND_UNAVAILABLE_MESSAGE)],
103
+ structuredContent=payload,
104
+ isError=True,
105
+ )
106
+
107
+
108
+ mcp = _ToolgraphMCP("toolgraph")
109
+
110
+ _with_generation = queries.with_graph_state
111
+
112
+
113
+ @mcp.read_only_tool()
114
+ def check_access(agent: str, tool: str) -> dict:
115
+ """Can `agent` call `tool`, and does the call path cross a DENY policy?
116
+
117
+ Returns a verdict (AGENT_NOT_FOUND | TOOL_NOT_FOUND | AMBIGUOUS_TOOL |
118
+ NOT_GRANTED | DENY | ALLOW) with the exact resource/policy evidence path.
119
+ Every response carries ``agent_found: bool`` and ``found`` for the tool
120
+ (True/False when resolution ran; null on AGENT_NOT_FOUND). On DENY, the
121
+ response also carries ``all_authorized: bool`` — True when every
122
+ deny_evidence row is an operator-declared exception.
123
+ """
124
+ return _with_generation(lambda: queries.check_access(agent, tool))
125
+
126
+
127
+ @mcp.read_only_tool()
128
+ def unsafe_callable_tools(agent: str, include_authorized: bool = False) -> dict:
129
+ """Tools `agent` is allowed to call that reach a DENY policy.
130
+
131
+ Returns an object so the MCP structured-content shape is stable:
132
+ ``{"agent", "agent_found", "count", "tools": [...]}``.
133
+
134
+ Defaults to ``classification='violation'`` rows only — matching the CLI
135
+ semantics so MCP consumers don't see authorized_but_governed rows mixed
136
+ in by default. Pass ``include_authorized=True`` for the full list.
137
+
138
+ ``agent_found=False`` distinguishes a typo'd agent (returns empty) from
139
+ an agent with zero unsafe tools (also returns empty).
140
+ """
141
+
142
+ def fetch() -> dict:
143
+ if not queries.agent_exists(agent):
144
+ return {"agent": agent, "agent_found": False, "count": 0, "tools": []}
145
+ tools = queries.unsafe_callable_tools(agent)
146
+ if not include_authorized:
147
+ tools = [t for t in tools if t.get("classification") == "violation"]
148
+ return {
149
+ "agent": agent,
150
+ "agent_found": True,
151
+ "count": len(tools),
152
+ "tools": tools,
153
+ }
154
+
155
+ return _with_generation(fetch)
156
+
157
+
158
+ @mcp.read_only_tool()
159
+ def blast_radius(node: str) -> dict:
160
+ """Agents/tools affected if `node` (a resource URI or a policy id) changes."""
161
+ return _with_generation(lambda: queries.blast_radius(node))
162
+
163
+
164
+ @mcp.read_only_tool()
165
+ def unmapped_tools(only_granted: bool = True) -> dict:
166
+ """Granted tools (or all tools if `only_granted=False`) with no authored
167
+ READS/WRITES — data-flow effects unclassified."""
168
+
169
+ def fetch() -> dict:
170
+ rows = queries.unmapped_tools(only_granted=only_granted)
171
+ return {"count": len(rows), "tools": rows}
172
+
173
+ return _with_generation(fetch)
174
+
175
+
176
+ @mcp.read_only_tool()
177
+ def orphan_policies() -> dict:
178
+ """Policies no agent currently reaches — likely obsolete or over-narrow."""
179
+
180
+ def fetch() -> dict:
181
+ rows = queries.orphan_policies()
182
+ return {"count": len(rows), "policies": rows}
183
+
184
+ return _with_generation(fetch)
185
+
186
+
187
+ @mcp.read_only_tool()
188
+ def unbacked_edges(include_grants: bool = False) -> dict:
189
+ """Authored edges with no usable evidence pointer.
190
+
191
+ Default scope is READS/WRITES/GOVERNED_BY — load-bearing claims about the
192
+ world. Pass ``include_grants=True`` to also surface CAN_CALL grants
193
+ (intent declarations, usually noise unless your policy requires every
194
+ grant to cite a change-request / runbook).
195
+ """
196
+
197
+ def fetch() -> dict:
198
+ rows = queries.unbacked_edges(include_grants=include_grants)
199
+ return {"count": len(rows), "edges": rows}
200
+
201
+ return _with_generation(fetch)
202
+
203
+
204
+ @mcp.read_only_tool()
205
+ def drifted_tools() -> dict:
206
+ """Tools with authored governance but no live EXPOSES edge — re-ingest needed."""
207
+
208
+ def fetch() -> dict:
209
+ rows = queries.drifted_tools()
210
+ return {"count": len(rows), "tools": rows}
211
+
212
+ return _with_generation(fetch)
213
+
214
+
215
+ @mcp.read_only_tool()
216
+ def destructive_unsafeguarded() -> dict:
217
+ """Tools hinting ``destructiveHint: true`` with no GOVERNED_BY path —
218
+ neither directly on the tool nor via any resource it reads/writes.
219
+
220
+ The hint is the server's self-claim (``crawled``/``medium``, ADR-0006):
221
+ these rows are an authoring priority queue, not violations. Annotations
222
+ never auto-create edges; the operator authors the policy binding.
223
+ """
224
+
225
+ def fetch() -> dict:
226
+ rows = queries.destructive_unsafeguarded()
227
+ return {"count": len(rows), "tools": rows}
228
+
229
+ return _with_generation(fetch)
230
+
231
+
232
+ @mcp.read_only_tool()
233
+ def annotation_contradictions() -> dict:
234
+ """Authored WRITES edges on tools hinting ``readOnlyHint: true``.
235
+
236
+ One side is wrong — the server's self-claim or the operator's assertion.
237
+ Each row cites both provenances so the operator can chase the stronger
238
+ evidence; the analyzer never picks a winner (ADR-0006).
239
+ """
240
+
241
+ def fetch() -> dict:
242
+ rows = queries.annotation_contradictions()
243
+ return {"count": len(rows), "contradictions": rows}
244
+
245
+ return _with_generation(fetch)
246
+
247
+
248
+ @mcp.read_only_tool()
249
+ def audit_report(include_grants: bool = False) -> dict:
250
+ """Whole-graph operational audit summary.
251
+
252
+ Aggregates unsafe DENY violations, operator-authorized DENY paths, drift,
253
+ unmapped tools, unbacked evidence, orphan policies, destructive annotation
254
+ gaps, and annotation contradictions. ``status`` is ``fail`` only for
255
+ blocking findings (unsafe violations or drifted governed tools), ``warn``
256
+ for advisory findings, and ``pass`` when the graph is clean.
257
+ """
258
+ return _with_generation(lambda: queries.audit_report(include_grants=include_grants))
259
+
260
+
261
+ @mcp.read_only_tool()
262
+ def rank_features(agent: str, candidates: list[str]) -> dict:
263
+ """Batch selection features for every candidate, in input order (ADR-0005).
264
+
265
+ Facts only — resolution (found/ambiguous/tool_key), grant, verdict, DENY
266
+ classification + evidence paths, drift, mapping, evidence coverage, the
267
+ four annotation self-claims — plus the rule-based ``risk_score`` from the
268
+ published fixed table. No relevance, no learning. ``agent_found: false``
269
+ means selection should abort: context-construction error, not an empty
270
+ result. Cache per ``graph_generation``.
271
+ """
272
+ return _with_generation(lambda: selector.rank_features(agent, candidates))
273
+
274
+
275
+ @mcp.read_only_tool()
276
+ def eligible_tools(
277
+ agent: str, candidates: list[str], profile: str = selector.DEFAULT_PROFILE
278
+ ) -> dict:
279
+ """Hard-filter candidates with reject reasons + policy-evidence paths.
280
+
281
+ ``profile`` is a named rule set — strict (production default) / review /
282
+ explore — not a learned threshold. ``eligible`` holds resolved tool keys
283
+ in candidate input order; each rejected row carries the input ref, the
284
+ winning reason, and for DENY reasons the graph paths that prove it. A
285
+ learned consumer may rerank ``eligible`` but may never resurrect a
286
+ rejected row (ADR-0005).
287
+ """
288
+ return _with_generation(
289
+ lambda: selector.eligible_tools(agent, candidates, profile=profile)
290
+ )
291
+
292
+
293
+ @mcp.read_only_tool()
294
+ def selection_explain(
295
+ agent: str, tool: str, profile: str = selector.DEFAULT_PROFILE
296
+ ) -> dict:
297
+ """Compact human-readable reasons why `tool` is eligible/rejected for
298
+ `agent` under `profile` — a view over the same facts ``rank_features``
299
+ returns, for operators and end users."""
300
+ return _with_generation(
301
+ lambda: selector.selection_explain(agent, tool, profile=profile)
302
+ )
303
+
304
+
305
+ if __name__ == "__main__":
306
+ mcp.run()
toolgraph/uris.py ADDED
@@ -0,0 +1,64 @@
1
+ """Resource-URI canonicalization, shared by the crawler, manifest, and queries.
2
+
3
+ MCP returns resource URIs as pydantic ``AnyUrl``, which normalizes (adds a
4
+ trailing slash to authority-only URLs, lowercases scheme/host, etc.). The
5
+ manifest is authored as raw strings. Both sides must canonicalize identically
6
+ or a governed resource and a crawled resource become two different graph nodes —
7
+ which would let a deny-governed resource slip past as ALLOW. Run every resource
8
+ URI through ``normalize_resource_uri`` at both write and read boundaries.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import warnings
15
+
16
+ from pydantic import AnyUrl, ValidationError
17
+
18
+
19
+ class ResourceUriNormalizationWarning(UserWarning):
20
+ """A URL-shaped resource identifier could not be canonicalized."""
21
+
22
+ # A URI scheme (RFC 3986): scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"
23
+ _SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
24
+ _TOOL_REF_PREFIX = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*::")
25
+
26
+
27
+ def normalize_resource_uri(uri: str) -> str:
28
+ """Canonicalize a resource URI the same way MCP's AnyUrl does.
29
+
30
+ Non-URL strings (custom identifiers AnyUrl can't parse) are returned
31
+ unchanged, so they still match consistently across write and read.
32
+ """
33
+ try:
34
+ return str(AnyUrl(uri))
35
+ except (ValidationError, ValueError):
36
+ if "://" in uri:
37
+ # A string that *looks* like a URL but fails to parse is exactly
38
+ # the identity-split hazard this module's docstring describes: if
39
+ # the other boundary's pydantic parses it, a deny-governed
40
+ # resource and its crawled twin become two different nodes. Custom
41
+ # non-URL identifiers (no "://") fall through silently by design.
42
+ warnings.warn(
43
+ "resource URI could not be canonicalized and is used verbatim;"
44
+ " if the crawler and manifest disagree about this URI, its"
45
+ " governance may not attach",
46
+ ResourceUriNormalizationWarning,
47
+ stacklevel=2,
48
+ )
49
+ return uri
50
+
51
+
52
+ def is_resource_ref(ref: str) -> bool:
53
+ """Distinguish a resource URI from a tool ref / policy id.
54
+
55
+ A resource URI carries a URI scheme (``scheme:`` — covers ``file:///x``,
56
+ ``memory:foo``, ``urn:secret``, ``file:/tmp/a``), whereas a tool ref is the
57
+ ``server::tool`` convention or a bare name. Only a leading ``server::``
58
+ prefix suppresses URI detection; IPv6 URI authorities such as
59
+ ``http://[::1]/x`` also contain ``::`` and must still be treated as
60
+ resources.
61
+ """
62
+ if _TOOL_REF_PREFIX.match(ref):
63
+ return False
64
+ return bool(_SCHEME.match(ref))
@@ -0,0 +1,68 @@
1
+ """Fail-closed YAML loading for operator-authored configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ class UniqueKeySafeLoader(yaml.SafeLoader):
12
+ """SafeLoader variant that rejects duplicate mapping keys at any depth."""
13
+
14
+
15
+ def _construct_mapping(
16
+ loader: UniqueKeySafeLoader, node: yaml.MappingNode, deep: bool = False
17
+ ) -> dict[Any, Any]:
18
+ loader.flatten_mapping(node)
19
+ mapping: dict[Any, Any] = {}
20
+ for key_node, value_node in node.value:
21
+ key = loader.construct_object(key_node, deep=deep)
22
+ try:
23
+ duplicate = key in mapping
24
+ except TypeError as exc:
25
+ raise yaml.constructor.ConstructorError(
26
+ "while constructing a mapping",
27
+ node.start_mark,
28
+ "found an unhashable key",
29
+ key_node.start_mark,
30
+ ) from exc
31
+ if duplicate:
32
+ raise yaml.constructor.ConstructorError(
33
+ "while constructing a mapping",
34
+ node.start_mark,
35
+ f"found duplicate key {key!r}",
36
+ key_node.start_mark,
37
+ )
38
+ mapping[key] = loader.construct_object(value_node, deep=deep)
39
+ return mapping
40
+
41
+
42
+ UniqueKeySafeLoader.add_constructor(
43
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping
44
+ )
45
+
46
+
47
+ MAX_YAML_BYTES = 5_000_000
48
+
49
+
50
+ def load_yaml_mapping(path: Path) -> dict[str, Any]:
51
+ """Load a size-bounded YAML mapping; reject duplicate keys, non-mapping roots."""
52
+ # Bounded read from one open descriptor (stat-then-read is a TOCTOU).
53
+ with path.open("rb") as handle:
54
+ raw = handle.read(MAX_YAML_BYTES + 1)
55
+ if len(raw) > MAX_YAML_BYTES:
56
+ raise ValueError(
57
+ f"refusing to load {path}: exceeds the {MAX_YAML_BYTES}-byte limit"
58
+ )
59
+ loader = UniqueKeySafeLoader(raw.decode("utf-8"))
60
+ try:
61
+ value = loader.get_single_data()
62
+ finally:
63
+ loader.dispose()
64
+ if value is None:
65
+ return {}
66
+ if not isinstance(value, dict):
67
+ raise ValueError(f"expected a YAML mapping at document root: {path}")
68
+ return value