dataform-context-mcp 0.5.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.
@@ -0,0 +1,3 @@
1
+ """Deterministic, self-hosted MCP server exposing structured Dataform pipeline context."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,229 @@
1
+ """Command-line entry point: index | report | serve | validate-golden."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from .compile import load_graph
12
+ from .db import (
13
+ AmbiguousError,
14
+ NotFoundError,
15
+ get_action,
16
+ get_meta,
17
+ open_db,
18
+ rebuild,
19
+ resolve,
20
+ traverse,
21
+ )
22
+ from .golden import GOLDEN_RELPATH, locate_golden, validate_entries
23
+ from .indexer import default_db_path, ensure_fresh
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="dataform-context",
29
+ description="Deterministic Dataform pipeline context: indexing CLI and MCP server.",
30
+ )
31
+ subparsers = parser.add_subparsers(dest="command")
32
+
33
+ p_index = subparsers.add_parser(
34
+ "index", help="Compile the Dataform repo and (re)build the context index"
35
+ )
36
+ p_index.add_argument(
37
+ "--repo",
38
+ type=Path,
39
+ default=Path("."),
40
+ help="Path to the Dataform repository (default: current directory)",
41
+ )
42
+ p_index.add_argument("--db", type=Path, help="Index database path (default: cache dir)")
43
+ p_index.add_argument(
44
+ "--from-json",
45
+ dest="from_json",
46
+ type=Path,
47
+ help="Load a captured `dataform compile --json` output instead of compiling (tests)",
48
+ )
49
+
50
+ p_report = subparsers.add_parser("report", help="Print a summary of the indexed graph")
51
+ p_report.add_argument(
52
+ "--repo",
53
+ type=Path,
54
+ default=Path("."),
55
+ help="Path to the Dataform repository (default: current directory)",
56
+ )
57
+ p_report.add_argument("--db", type=Path, help="Index database path (default: cache dir)")
58
+ p_report.add_argument("--table", help="Print the full context of one table as JSON")
59
+
60
+ p_serve = subparsers.add_parser("serve", help="Run the MCP server (stdio)")
61
+ p_serve.add_argument(
62
+ "--repo",
63
+ type=Path,
64
+ default=Path("."),
65
+ help="Path to the Dataform repository (default: current directory — MCP clients "
66
+ "launch project servers from the project root)",
67
+ )
68
+ p_serve.add_argument("--db", type=Path)
69
+
70
+ p_golden = subparsers.add_parser(
71
+ "validate-golden",
72
+ help="Validate column lineage against a golden file "
73
+ "(JSON list of {table, column, direction, depth, expected_edges, expect_complete})",
74
+ )
75
+ p_golden.add_argument("--repo", type=Path, default=Path("."))
76
+ p_golden.add_argument("--db", type=Path)
77
+ p_golden.add_argument(
78
+ "--golden",
79
+ type=Path,
80
+ help="Golden file (default: <repo>/.dataform-context/golden_columns.json)",
81
+ )
82
+ return parser
83
+
84
+
85
+ def _resolve_db(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Path:
86
+ if args.db:
87
+ return args.db
88
+ if args.repo:
89
+ return default_db_path(args.repo)
90
+ parser.error(f"{args.command}: --repo or --db is required")
91
+ raise AssertionError # unreachable
92
+
93
+
94
+ def cmd_index(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
95
+ db_path = _resolve_db(args, parser)
96
+ started = time.perf_counter()
97
+ if args.from_json:
98
+ graph = load_graph(json.loads(args.from_json.read_text()))
99
+ conn = open_db(db_path)
100
+ rebuild(conn, graph, source_hash="manual")
101
+ meta = get_meta(conn)
102
+ counts = meta["counts"]
103
+ else:
104
+ if not args.repo:
105
+ parser.error("index: --repo is required unless --from-json is given")
106
+ conn, index_meta = ensure_fresh(args.repo, db_path)
107
+ if index_meta["compile_status"] == "error":
108
+ print(f"compile error:\n{index_meta['compile_error']}", file=sys.stderr)
109
+ return 1
110
+ counts = index_meta["counts"]
111
+ elapsed = time.perf_counter() - started
112
+ print(f"db: {db_path}")
113
+ print(f"actions: {counts['actions']}")
114
+ print(f"table_edges: {counts['table_edges']}")
115
+ print(f"compile_seconds: {elapsed:.1f}")
116
+ return 0
117
+
118
+
119
+ def cmd_report(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
120
+ db_path = _resolve_db(args, parser)
121
+ if not Path(db_path).exists():
122
+ print(f"no index at {db_path} — run `dataform-context index` first", file=sys.stderr)
123
+ return 2
124
+ conn = open_db(db_path)
125
+ if args.table:
126
+ try:
127
+ action_id = resolve(conn, args.table)
128
+ except NotFoundError as err:
129
+ print(f"'{args.table}' not found. Suggestions: {err.suggestions}", file=sys.stderr)
130
+ return 2
131
+ except AmbiguousError as err:
132
+ print(f"'{args.table}' is ambiguous. Candidates: {err.candidates}", file=sys.stderr)
133
+ return 2
134
+ action = get_action(conn, action_id)
135
+ detail = {
136
+ "canonical": action["canonical"],
137
+ "type": action["action_type"],
138
+ "layer": action["layer"],
139
+ "file": action["file_name"],
140
+ "description": action["description"],
141
+ "tags": action["tags"],
142
+ "columns": action["columns"],
143
+ "upstream": [
144
+ n["canonical"] for lvl in traverse(conn, action_id, "up", 1) for n in lvl["nodes"]
145
+ ],
146
+ "downstream": [
147
+ n["canonical"] for lvl in traverse(conn, action_id, "down", 1) for n in lvl["nodes"]
148
+ ],
149
+ }
150
+ print(json.dumps(detail, indent=2))
151
+ return 0
152
+ meta = get_meta(conn)
153
+ print(f"indexed_at: {meta.get('indexed_at', '?')}")
154
+ print(f"actions: {meta['counts']['actions']}")
155
+ print(f"table_edges: {meta['counts']['table_edges']}")
156
+ print("by layer:")
157
+ for layer, count in conn.execute(
158
+ "SELECT COALESCE(layer, '(none)'), COUNT(*) FROM actions GROUP BY layer ORDER BY layer"
159
+ ):
160
+ print(f" {layer}: {count}")
161
+ extraction = meta.get("column_extraction", {})
162
+ if extraction:
163
+ analyzable = sum(count for status, count in extraction.items() if status != "source")
164
+ pct_ok = 100 * extraction.get("ok", 0) / analyzable if analyzable else 0.0
165
+ print("column extraction:")
166
+ for status in sorted(extraction):
167
+ print(f" {status}: {extraction[status]}")
168
+ print(f" pct_ok (hors source): {pct_ok:.0f}%")
169
+ return 0
170
+
171
+
172
+ def cmd_validate_golden(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
173
+ db_path = _resolve_db(args, parser)
174
+ if not Path(db_path).exists():
175
+ print(f"no index at {db_path} — run `dataform-context index` first", file=sys.stderr)
176
+ return 2
177
+ misplaced = None
178
+ if args.golden:
179
+ golden_path = args.golden
180
+ elif args.repo:
181
+ golden_path, misplaced = locate_golden(args.repo)
182
+ else:
183
+ golden_path = None
184
+ if golden_path is None or not Path(golden_path).exists():
185
+ print(f"golden file not found: {golden_path}", file=sys.stderr)
186
+ if misplaced:
187
+ print(
188
+ f"found one at {misplaced} instead — move it to "
189
+ f"{GOLDEN_RELPATH.as_posix()}, or pass --golden {misplaced}",
190
+ file=sys.stderr,
191
+ )
192
+ return 2
193
+ entries = json.loads(Path(golden_path).read_text())
194
+ conn = open_db(db_path)
195
+ summary = validate_entries(conn, entries)
196
+ for result in summary["results"]:
197
+ if result["ok"]:
198
+ print(
199
+ f"PASS {result['label']} ({result['edges']} edges, complete={result['complete']})"
200
+ )
201
+ else:
202
+ print(f"FAIL {result['label']}: " + " | ".join(result["problems"]))
203
+ print(f"{summary['passed']}/{summary['total']} golden entries passed")
204
+ return 0 if summary["passed"] == summary["total"] else 1
205
+
206
+
207
+ def main(argv: list[str] | None = None) -> int:
208
+ parser = build_parser()
209
+ args = parser.parse_args(argv)
210
+ if args.command is None:
211
+ parser.print_help()
212
+ return 1
213
+ if args.command == "index":
214
+ return cmd_index(args, parser)
215
+ if args.command == "report":
216
+ return cmd_report(args, parser)
217
+ if args.command == "serve":
218
+ from .server import create_server
219
+
220
+ create_server(args.repo, args.db).run("stdio")
221
+ return 0
222
+ if args.command == "validate-golden":
223
+ return cmd_validate_golden(args, parser)
224
+ print(f"{args.command}: not implemented", file=sys.stderr)
225
+ return 1
226
+
227
+
228
+ if __name__ == "__main__":
229
+ sys.exit(main())
@@ -0,0 +1,103 @@
1
+ """Run `dataform compile --json` and load the CompiledGraph into the typed model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from .model import Action, ColumnDoc, CompiledGraphData, Target
10
+
11
+ _STDERR_CAP = 4000
12
+
13
+ # enumType of entries in the `tables` array; anything unexpected degrades to "table".
14
+ _ENUM_TYPES = {"TABLE": "table", "VIEW": "view", "INCREMENTAL": "incremental"}
15
+
16
+
17
+ class CompileError(Exception):
18
+ def __init__(self, message: str, exit_code: int | None = None, stderr: str = ""):
19
+ super().__init__(message)
20
+ self.exit_code = exit_code
21
+ self.stderr = stderr
22
+
23
+
24
+ def run_dataform_compile(repo: Path, timeout: float = 120.0) -> dict:
25
+ repo = Path(repo)
26
+ if not repo.is_dir():
27
+ raise CompileError(f"repo not found: {repo}", stderr=f"not a directory: {repo}")
28
+ try:
29
+ proc = subprocess.run(
30
+ ["dataform", "compile", "--json"],
31
+ cwd=repo,
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=timeout,
35
+ )
36
+ except FileNotFoundError as err:
37
+ raise CompileError("dataform CLI not found on PATH", stderr=str(err)) from err
38
+ except subprocess.TimeoutExpired as err:
39
+ raise CompileError(f"dataform compile timed out after {timeout}s", stderr=str(err)) from err
40
+ if proc.returncode != 0:
41
+ raise CompileError(
42
+ f"dataform compile failed (exit {proc.returncode})",
43
+ exit_code=proc.returncode,
44
+ stderr=(proc.stderr or proc.stdout)[-_STDERR_CAP:],
45
+ )
46
+ try:
47
+ raw = json.loads(proc.stdout)
48
+ except json.JSONDecodeError as err:
49
+ raise CompileError("dataform compile produced invalid JSON", stderr=str(err)) from err
50
+ compilation_errors = (raw.get("graphErrors") or {}).get("compilationErrors") or []
51
+ if compilation_errors:
52
+ raise CompileError(
53
+ f"dataform compilation errors ({len(compilation_errors)})",
54
+ stderr=json.dumps(compilation_errors)[:_STDERR_CAP],
55
+ )
56
+ return raw
57
+
58
+
59
+ def load_graph(raw: dict) -> CompiledGraphData:
60
+ actions: list[Action] = []
61
+ for entry in raw.get("tables") or []:
62
+ actions.append(_load_action(entry, _ENUM_TYPES.get(entry.get("enumType", ""), "table")))
63
+ for entry in raw.get("declarations") or []:
64
+ actions.append(_load_action(entry, "declaration"))
65
+ for entry in raw.get("operations") or []:
66
+ actions.append(_load_action(entry, "operations"))
67
+ for entry in raw.get("assertions") or []:
68
+ actions.append(_load_action(entry, "assertion"))
69
+ return CompiledGraphData(actions=actions, dataform_version=raw.get("dataformCoreVersion"))
70
+
71
+
72
+ def _load_target(entry: dict) -> Target:
73
+ return Target(
74
+ database=entry.get("database", ""),
75
+ schema=entry.get("schema", ""),
76
+ name=entry.get("name", ""),
77
+ )
78
+
79
+
80
+ def _load_action(entry: dict, action_type: str) -> Action:
81
+ descriptor = entry.get("actionDescriptor") or {}
82
+ columns = [
83
+ ColumnDoc(name=".".join(col["path"]), description=col.get("description"))
84
+ for col in descriptor.get("columns") or []
85
+ if col.get("path")
86
+ ]
87
+ # operations carry a `queries` array instead of `query`
88
+ query = entry.get("query")
89
+ if not query and entry.get("queries"):
90
+ query = ";\n".join(q for q in entry["queries"] if q)
91
+ return Action(
92
+ target=_load_target(entry.get("target") or {}),
93
+ action_type=action_type,
94
+ file_name=entry.get("fileName"),
95
+ tags=list(entry.get("tags") or []),
96
+ description=descriptor.get("description"),
97
+ columns=columns,
98
+ dependency_targets=[_load_target(t) for t in entry.get("dependencyTargets") or []],
99
+ query=query,
100
+ incremental_query=entry.get("incrementalQuery"),
101
+ unique_key=list(entry.get("uniqueKey") or []),
102
+ disabled=bool(entry.get("disabled", False)),
103
+ )