pretensor 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.
Files changed (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,142 @@
1
+ """Per-process MCP server context (visibility filter bound to ``graph_dir``).
2
+
3
+ The active context is stored in a :class:`contextvars.ContextVar` rather than a
4
+ bare mutable module global. In the current stdio server it is set once at
5
+ startup, so every async task observes the same value; the ContextVar's benefit
6
+ is removing ``global`` mutation and giving each thread / async context an
7
+ independent binding, which future-proofs the context for a concurrent transport
8
+ (HTTP/SSE) without reintroducing a shared-mutable-global hazard.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextvars
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from pretensor.config import GraphConfig, PretensorConfig
18
+ from pretensor.mcp.store_cache import StoreCache
19
+ from pretensor.search.base import BaseSearchIndex
20
+ from pretensor.search.index import KeywordSearchIndex
21
+ from pretensor.visibility.filter import VisibilityFilter
22
+ from pretensor.visibility.runtime import load_visibility_filter_for_graph_dir
23
+
24
+ __all__ = [
25
+ "DEFAULT_GRAPH_CONFIG",
26
+ "ServerContext",
27
+ "build_server_context",
28
+ "get_effective_graph_config",
29
+ "get_effective_search_index_cls",
30
+ "get_effective_visibility_filter",
31
+ "get_server_context",
32
+ "reset_server_context",
33
+ "set_server_context",
34
+ ]
35
+
36
+ DEFAULT_GRAPH_CONFIG = GraphConfig()
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ServerContext:
41
+ """Holds graph workspace path and merged visibility rules for tool handlers."""
42
+
43
+ graph_dir: Path
44
+ visibility_filter: VisibilityFilter
45
+ config: PretensorConfig = field(default_factory=PretensorConfig)
46
+ store_cache: StoreCache = field(default_factory=StoreCache)
47
+
48
+ @property
49
+ def graph_config(self) -> GraphConfig:
50
+ """Backward-compatible access to graph tuning config."""
51
+ return self.config.graph
52
+
53
+ @property
54
+ def search_index_cls(self) -> type[BaseSearchIndex]:
55
+ """Backward-compatible access to configured search index class."""
56
+ return self.config.search_index_cls
57
+
58
+
59
+ _ctx: contextvars.ContextVar[ServerContext | None] = contextvars.ContextVar(
60
+ "pretensor_mcp_server_context", default=None
61
+ )
62
+
63
+
64
+ def set_server_context(ctx: ServerContext) -> None:
65
+ """Install context for the current MCP server process (stdio)."""
66
+ _ctx.set(ctx)
67
+
68
+
69
+ def reset_server_context() -> None:
70
+ """Clear the active server context.
71
+
72
+ Closes any open stores in the cache before clearing. Intended for test
73
+ teardown only — production servers never need to call this.
74
+ """
75
+ ctx = _ctx.get()
76
+ if ctx is not None:
77
+ ctx.store_cache.close()
78
+ _ctx.set(None)
79
+
80
+
81
+ def get_server_context() -> ServerContext:
82
+ """Return the active server context (must be set before handling tools)."""
83
+ val = _ctx.get()
84
+ if val is None:
85
+ raise RuntimeError("MCP server context not initialized")
86
+ return val
87
+
88
+
89
+ def build_server_context(
90
+ graph_dir: Path,
91
+ *,
92
+ visibility_path: Path | None = None,
93
+ profile: str | None = None,
94
+ config: PretensorConfig | None = None,
95
+ ) -> ServerContext:
96
+ """Resolve ``graph_dir`` and load visibility for one server lifetime."""
97
+ resolved = graph_dir.resolve()
98
+ vf = load_visibility_filter_for_graph_dir(
99
+ resolved,
100
+ visibility_path=visibility_path,
101
+ profile=profile,
102
+ )
103
+ effective_config = config or PretensorConfig()
104
+ return ServerContext(
105
+ graph_dir=resolved,
106
+ visibility_filter=vf,
107
+ config=effective_config,
108
+ )
109
+
110
+
111
+ def get_effective_visibility_filter(
112
+ explicit: VisibilityFilter | None = None,
113
+ ) -> VisibilityFilter | None:
114
+ """Return an explicit filter, the server context filter, or None (no MCP server)."""
115
+ if explicit is not None:
116
+ return explicit
117
+ try:
118
+ return get_server_context().visibility_filter
119
+ except RuntimeError:
120
+ return None
121
+
122
+
123
+ def get_effective_graph_config(explicit: GraphConfig | None = None) -> GraphConfig:
124
+ """Return explicit graph config or server context fallback."""
125
+ if explicit is not None:
126
+ return explicit
127
+ try:
128
+ return get_server_context().config.graph
129
+ except RuntimeError:
130
+ return DEFAULT_GRAPH_CONFIG
131
+
132
+
133
+ def get_effective_search_index_cls(
134
+ explicit: type[BaseSearchIndex] | None = None,
135
+ ) -> type[BaseSearchIndex]:
136
+ """Return an explicit class, context class, or the OSS default."""
137
+ if explicit is not None:
138
+ return explicit
139
+ try:
140
+ return get_server_context().config.search_index_cls
141
+ except RuntimeError:
142
+ return KeywordSearchIndex
@@ -0,0 +1,294 @@
1
+ """Registry, keystore, and Kuzu open helpers for MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Literal, NamedTuple
9
+
10
+ from pretensor.core.dsn_crypto import DSNEncryptor
11
+ from pretensor.core.registry import GraphRegistry, RegistryEntry
12
+ from pretensor.core.store import KuzuStore
13
+ from pretensor.mcp.service_context import get_server_context
14
+ from pretensor.visibility.filter import VisibilityFilter
15
+
16
+ CapabilityState = Literal["not_attempted", "empty", "present"]
17
+
18
+
19
+ class GraphCounts(NamedTuple):
20
+ """Per-database counts and capability flags derived from the graph in one pass."""
21
+
22
+ table_count: int
23
+ column_count: int
24
+ row_count: int
25
+ schemas: list[str]
26
+ has_dbt_manifest: CapabilityState
27
+ has_llm_enrichment: CapabilityState
28
+ has_external_consumers: CapabilityState
29
+
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def load_registry(graph_dir: Path) -> GraphRegistry:
35
+ path = graph_dir / "registry.json"
36
+ if not path.exists():
37
+ return GraphRegistry(path)
38
+ try:
39
+ return GraphRegistry(path).load()
40
+ except (json.JSONDecodeError, KeyError, TypeError) as exc:
41
+ logger.error("Registry file %s is corrupt: %s", path, exc)
42
+ return GraphRegistry(path)
43
+ except Exception as exc:
44
+ logger.warning("Failed to load registry from %s: %s", path, exc)
45
+ return GraphRegistry(path)
46
+
47
+
48
+ def resolve_registry_entry(
49
+ registry: GraphRegistry, db: str | None
50
+ ) -> RegistryEntry | None:
51
+ """Resolve ``db`` by ``connection_name`` or logical ``database``; None picks a unique entry."""
52
+ entries = registry.list_entries()
53
+ if not entries:
54
+ return None
55
+ if db is None:
56
+ if len(entries) == 1:
57
+ return entries[0]
58
+ return None
59
+ for entry in entries:
60
+ if entry.connection_name == db or entry.database == db:
61
+ return entry
62
+ return None
63
+
64
+
65
+ def keystore_path(graph_dir: Path) -> Path:
66
+ return graph_dir / "keystore"
67
+
68
+
69
+ def encryptor_for_dir(graph_dir: Path) -> DSNEncryptor:
70
+ return DSNEncryptor(keystore_path(graph_dir))
71
+
72
+
73
+ def entry_plaintext_dsn(entry: RegistryEntry, graph_dir: Path) -> str:
74
+ """Decrypt registry DSN when stored encrypted."""
75
+ return entry.plaintext_dsn(
76
+ encryptor_for_dir(graph_dir) if entry.dsn_encrypted else None
77
+ )
78
+
79
+
80
+ def graph_path_for_entry(entry: RegistryEntry) -> Path:
81
+ return Path(entry.unified_graph_path or entry.graph_path)
82
+
83
+
84
+ def _active_store_cache():
85
+ try:
86
+ return get_server_context().store_cache
87
+ except RuntimeError:
88
+ return None
89
+
90
+
91
+ def _open_uncaught(graph_path: Path) -> KuzuStore:
92
+ """Open (or fetch from the active cache) a store, letting any error propagate raw.
93
+
94
+ The public ``open_store_*`` helpers wrap the raw error in a ``RuntimeError``
95
+ with a caller-appropriate message (path-based vs connection-based).
96
+ """
97
+ cache = _active_store_cache()
98
+ if cache is not None:
99
+ return cache.get(graph_path)
100
+ store = KuzuStore(graph_path)
101
+ store.ensure_schema()
102
+ return store
103
+
104
+
105
+ def open_store_for_path(graph_path: Path) -> KuzuStore:
106
+ """Return a cached store for this path when a server context is active, else a fresh one."""
107
+ try:
108
+ return _open_uncaught(graph_path)
109
+ except Exception as exc:
110
+ raise RuntimeError(
111
+ f"Cannot open graph file ({graph_path}): {exc}. "
112
+ f"The file may be corrupt — re-run `pretensor index`."
113
+ ) from exc
114
+
115
+
116
+ def open_store_for_entry(entry: RegistryEntry) -> KuzuStore:
117
+ gp = graph_path_for_entry(entry)
118
+ try:
119
+ return _open_uncaught(gp)
120
+ except Exception as exc:
121
+ # Reference the connection by name only — the on-disk graph path and the
122
+ # underlying engine error are kept off the message so they cannot leak
123
+ # to a semi-trusted MCP client. The original exception is preserved via
124
+ # ``from exc`` for server-side logs.
125
+ raise RuntimeError(
126
+ f"Cannot open graph file for connection {entry.connection_name!r}. "
127
+ "The graph may be missing or corrupt — re-run indexing."
128
+ ) from exc
129
+
130
+
131
+ def release_store(store: KuzuStore) -> None:
132
+ """Close a store unless it is owned by the active server-context cache (pooled)."""
133
+ cache = _active_store_cache()
134
+ if cache is not None and cache.owns(store):
135
+ return
136
+ store.close()
137
+
138
+
139
+ def _column_count_for_connection(
140
+ store: KuzuStore,
141
+ *,
142
+ connection_name: str | None = None,
143
+ ) -> int:
144
+ """Return the number of ``SchemaColumn`` nodes, optionally scoped to a connection."""
145
+ if connection_name is None:
146
+ r = store.query_all_rows("MATCH (c:SchemaColumn) RETURN count(*)")
147
+ else:
148
+ r = store.query_all_rows(
149
+ "MATCH (c:SchemaColumn) WHERE c.connection_name = $cn RETURN count(*)",
150
+ {"cn": connection_name},
151
+ )
152
+ if not r:
153
+ return 0
154
+ val = r[0][0]
155
+ return int(val) if val is not None else 0
156
+
157
+
158
+ _DBT_MODEL_DEPENDENCY_LINEAGE_TYPE = "model_dependency"
159
+
160
+
161
+ def _has_dbt_manifest(store: KuzuStore, *, connection_name: str | None) -> bool:
162
+ """True when any ``LINEAGE`` edge with ``lineage_type='model_dependency'`` exists.
163
+
164
+ dbt's ``manifest.json`` is not stored as a graph node; its ``parent_map`` is
165
+ projected as ``LINEAGE`` edges tagged with this ``lineage_type`` (see
166
+ ``src/pretensor/enrichment/dbt/lineage.py``).
167
+ """
168
+ if connection_name is None:
169
+ rows = store.query_all_rows(
170
+ """
171
+ MATCH ()-[r:LINEAGE]->()
172
+ WHERE r.lineage_type = $lt
173
+ RETURN 1 LIMIT 1
174
+ """,
175
+ {"lt": _DBT_MODEL_DEPENDENCY_LINEAGE_TYPE},
176
+ )
177
+ else:
178
+ rows = store.query_all_rows(
179
+ """
180
+ MATCH (s:SchemaTable)-[r:LINEAGE]->()
181
+ WHERE s.connection_name = $cn AND r.lineage_type = $lt
182
+ RETURN 1 LIMIT 1
183
+ """,
184
+ {"cn": connection_name, "lt": _DBT_MODEL_DEPENDENCY_LINEAGE_TYPE},
185
+ )
186
+ return len(rows) > 0
187
+
188
+
189
+ def _has_llm_enrichment(store: KuzuStore, *, connection_name: str | None) -> bool:
190
+ """True when any ``Entity`` node exists (LLM enrichment output)."""
191
+ if connection_name is None:
192
+ rows = store.query_all_rows("MATCH (e:Entity) RETURN 1 LIMIT 1")
193
+ else:
194
+ rows = store.query_all_rows(
195
+ "MATCH (e:Entity) WHERE e.connection_name = $cn RETURN 1 LIMIT 1",
196
+ {"cn": connection_name},
197
+ )
198
+ return len(rows) > 0
199
+
200
+
201
+ def counts_for_graph(
202
+ store: KuzuStore,
203
+ *,
204
+ connection_name: str | None = None,
205
+ visibility_filter: VisibilityFilter | None = None,
206
+ entry: RegistryEntry | None = None,
207
+ ) -> GraphCounts:
208
+ """Return per-database counts and capability flags in a single store pass.
209
+
210
+ Optionally scoped to one connection. Visibility filtering applies to table
211
+ counts, ``schemas``, and ``has_external_consumers`` (hidden tables do not
212
+ contribute); it does not affect the dbt/Entity probes, which are graph-wide.
213
+ """
214
+ params = {"cn": connection_name} if connection_name is not None else {}
215
+ cn_clause = "WHERE t.connection_name = $cn" if connection_name is not None else ""
216
+ rows = store.query_all_rows(
217
+ f"""
218
+ MATCH (t:SchemaTable)
219
+ {cn_clause}
220
+ RETURN t.connection_name, t.schema_name, t.table_name, t.row_count,
221
+ t.has_external_consumers
222
+ """,
223
+ params,
224
+ )
225
+ total = 0
226
+ row_sum = 0
227
+ schemas: set[str] = set()
228
+ has_external = False
229
+ for cn, sn, tn, rc, hec in rows:
230
+ cns = str(cn or "")
231
+ sns = str(sn or "")
232
+ tns = str(tn or "")
233
+ if visibility_filter is not None and not visibility_filter.is_table_visible(
234
+ cns, sns, tns
235
+ ):
236
+ continue
237
+ total += 1
238
+ if rc is not None:
239
+ row_sum += int(rc)
240
+ if sns:
241
+ schemas.add(sns)
242
+ if bool(hec):
243
+ has_external = True
244
+ cc = _column_count_for_connection(store, connection_name=connection_name)
245
+ dbt_present = _has_dbt_manifest(store, connection_name=connection_name)
246
+ llm_present = _has_llm_enrichment(store, connection_name=connection_name)
247
+ # Trinary states use entry provenance when available: "not_attempted" means
248
+ # the user never ran that enrichment pass (so callers shouldn't infer absence
249
+ # of signal). An indexer that did attempt it returns "empty" vs "present".
250
+ dbt_attempted = entry is not None and entry.dbt_manifest_path is not None
251
+ if not dbt_attempted and entry is not None:
252
+ has_dbt: CapabilityState = "not_attempted"
253
+ else:
254
+ has_dbt = (
255
+ "present"
256
+ if dbt_present
257
+ else ("empty" if dbt_attempted else "not_attempted")
258
+ )
259
+ llm_attempted = entry is not None and entry.llm_enrichment_ran
260
+ if entry is None:
261
+ has_llm: CapabilityState = "present" if llm_present else "empty"
262
+ else:
263
+ has_llm = (
264
+ "present"
265
+ if llm_present
266
+ else ("empty" if llm_attempted else "not_attempted")
267
+ )
268
+ # External-consumer scanner is not shipped yet; unify the "never checked"
269
+ # default with the dbt/LLM flags so callers don't have to special-case it.
270
+ has_ext: CapabilityState = "present" if has_external else "not_attempted"
271
+ return GraphCounts(
272
+ table_count=total,
273
+ column_count=cc,
274
+ row_count=row_sum,
275
+ schemas=sorted(schemas),
276
+ has_dbt_manifest=has_dbt,
277
+ has_llm_enrichment=has_llm,
278
+ has_external_consumers=has_ext,
279
+ )
280
+
281
+
282
+ __all__ = [
283
+ "GraphCounts",
284
+ "counts_for_graph",
285
+ "encryptor_for_dir",
286
+ "entry_plaintext_dsn",
287
+ "graph_path_for_entry",
288
+ "keystore_path",
289
+ "load_registry",
290
+ "open_store_for_entry",
291
+ "open_store_for_path",
292
+ "release_store",
293
+ "resolve_registry_entry",
294
+ ]
@@ -0,0 +1,43 @@
1
+ """Per-server-lifetime cache of open KuzuStore handles, keyed by resolved graph path.
2
+
3
+ The MCP server is single-threaded (stdio + one asyncio loop), so a single reused
4
+ KuzuStore (one kuzu.Connection) per graph path is safe and avoids re-opening the
5
+ database + re-running ensure_schema() on every tool call. Cross-process staleness
6
+ caveat: a separate `pretensor reindex` process writing the same file is not
7
+ reflected in an already-open cached read handle until the server restarts — this
8
+ matches the previous behavior's freshness boundary for the server's lifetime.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from pathlib import Path
15
+
16
+ from pretensor.core.store import KuzuStore
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class StoreCache:
22
+ def __init__(self) -> None:
23
+ self._stores: dict[Path, KuzuStore] = {}
24
+
25
+ def get(self, graph_path: Path) -> KuzuStore:
26
+ key = graph_path.resolve()
27
+ store = self._stores.get(key)
28
+ if store is None:
29
+ store = KuzuStore(key)
30
+ store.ensure_schema()
31
+ self._stores[key] = store
32
+ return store
33
+
34
+ def owns(self, store: KuzuStore) -> bool:
35
+ return any(s is store for s in self._stores.values())
36
+
37
+ def close(self) -> None:
38
+ for path, store in self._stores.items():
39
+ try:
40
+ store.close()
41
+ except Exception:
42
+ logger.warning("Error closing cached store for %s", path, exc_info=True)
43
+ self._stores.clear()
@@ -0,0 +1,136 @@
1
+ """MCP tool plugin registry — McpTool and McpToolRegistry.
2
+
3
+ Allows dynamic, pluggable registration of MCP tools so that extensions
4
+ can add tools (e.g. ``semantic_search``) without modifying
5
+ OSS server code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from typing import Any, Awaitable, Callable
14
+
15
+ import mcp.types as types
16
+
17
+ __all__ = ["McpTool", "McpToolRegistry"]
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]
22
+
23
+
24
+ @dataclass
25
+ class McpTool:
26
+ """Descriptor and handler for a single MCP tool.
27
+
28
+ Args:
29
+ name: Unique tool name exposed to the MCP client.
30
+ description: Human-readable description shown to the client.
31
+ input_schema: JSON Schema object describing the tool's input.
32
+ handler: Async callable that receives ``arguments`` and returns a
33
+ JSON-serialisable ``dict``.
34
+ """
35
+
36
+ name: str
37
+ description: str
38
+ input_schema: dict[str, Any]
39
+ handler: ToolHandler
40
+
41
+ def to_mcp_type(self) -> types.Tool:
42
+ """Return the :class:`mcp.types.Tool` representation."""
43
+ return types.Tool(
44
+ name=self.name,
45
+ description=self.description,
46
+ inputSchema=self.input_schema,
47
+ )
48
+
49
+
50
+ class McpToolRegistry:
51
+ """Registry that owns the canonical set of MCP tools for a server.
52
+
53
+ Usage::
54
+
55
+ registry = McpToolRegistry()
56
+ registry.register(McpTool(name="my_tool", ...))
57
+
58
+ # In the MCP list_tools handler:
59
+ tools = registry.list_tools()
60
+
61
+ # In the MCP call_tool handler:
62
+ result = await registry.call_tool("my_tool", {"arg": "value"})
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self._tools: dict[str, McpTool] = {}
67
+
68
+ def register(self, tool: McpTool) -> None:
69
+ """Register a tool; raises :exc:`ValueError` if the name is already taken.
70
+
71
+ Args:
72
+ tool: The tool descriptor and handler to register.
73
+
74
+ Raises:
75
+ ValueError: If a tool with the same name is already registered.
76
+ """
77
+ if tool.name in self._tools:
78
+ raise ValueError(f"Tool '{tool.name}' is already registered")
79
+ self._tools[tool.name] = tool
80
+ logger.debug("Registered MCP tool: %s", tool.name)
81
+
82
+ def list_tools(self) -> list[types.Tool]:
83
+ """Return all registered tools as :class:`mcp.types.Tool` objects.
84
+
85
+ Returns:
86
+ List of MCP tool descriptors in registration order.
87
+ """
88
+ return [t.to_mcp_type() for t in self._tools.values()]
89
+
90
+ async def call_tool(
91
+ self,
92
+ name: str,
93
+ arguments: dict[str, Any] | None,
94
+ ) -> dict[str, Any]:
95
+ """Dispatch a tool call to its registered handler.
96
+
97
+ All handler exceptions are caught and returned as an opaque error
98
+ envelope so the MCP server never crashes on a bad tool invocation and
99
+ never discloses internals (tracebacks, filesystem paths, exception
100
+ text) to the semi-trusted client. The full stack is kept server-side
101
+ via ``logger.exception`` tagged with the same ``correlation_id`` so an
102
+ operator can still join a client-reported id to the server log.
103
+
104
+ Args:
105
+ name: Tool name as sent by the MCP client.
106
+ arguments: Raw argument dict (may be ``None`` or empty).
107
+
108
+ Returns:
109
+ JSON-serialisable result dict from the handler, or an opaque
110
+ ``{"error": "Internal tool error", "tool": ..., "correlation_id":
111
+ ...}`` envelope when the handler raises.
112
+ """
113
+ tool = self._tools.get(name)
114
+ if tool is None:
115
+ return {"error": f"Unknown tool: {name}"}
116
+ args = arguments or {}
117
+ try:
118
+ return await tool.handler(args)
119
+ except Exception:
120
+ correlation_id = uuid.uuid4().hex
121
+ logger.exception(
122
+ "Unhandled exception in MCP tool %r [correlation_id=%s]",
123
+ name,
124
+ correlation_id,
125
+ )
126
+ return {
127
+ "error": "Internal tool error",
128
+ "tool": name,
129
+ "correlation_id": correlation_id,
130
+ }
131
+
132
+ def __len__(self) -> int:
133
+ return len(self._tools)
134
+
135
+ def __contains__(self, name: object) -> bool:
136
+ return name in self._tools
@@ -0,0 +1 @@
1
+ """MCP tool payload implementations (list, query, context, traverse, impact, detect_changes)."""