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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- 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)."""
|