dbt-agent-layer 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.
@@ -0,0 +1,3 @@
1
+ """dbt-agent-layer: Make your dbt metrics queryable by AI agents via MCP."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """CLI entrypoint."""
2
+
3
+ from .main import cli
4
+
5
+ __all__ = ["cli"]
@@ -0,0 +1,138 @@
1
+ """dbt-agent build command."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.command()
15
+ @click.option(
16
+ "--project-dir",
17
+ default=".",
18
+ show_default=True,
19
+ help="Path to dbt project directory.",
20
+ type=click.Path(file_okay=False),
21
+ )
22
+ @click.option(
23
+ "--skip-compile",
24
+ is_flag=True,
25
+ default=False,
26
+ help="Use existing manifest.json; skip dbt compile.",
27
+ )
28
+ @click.option(
29
+ "--dry-run",
30
+ is_flag=True,
31
+ default=False,
32
+ help="Print what would be generated; write nothing.",
33
+ )
34
+ @click.option(
35
+ "--verbose",
36
+ is_flag=True,
37
+ default=False,
38
+ help="Print each metric as it is parsed.",
39
+ )
40
+ def build_cmd(
41
+ project_dir: str,
42
+ skip_compile: bool,
43
+ dry_run: bool,
44
+ verbose: bool,
45
+ ) -> None:
46
+ """Parse the dbt project and generate MCP tool metadata."""
47
+ from ..config.settings import load_config
48
+ from ..parser.project import DbtProject
49
+ from ..parser.manifest import DbtManifest
50
+
51
+ project_path = Path(project_dir).expanduser().resolve()
52
+ config = load_config(str(project_path))
53
+
54
+ # 1. Compile dbt if needed
55
+ if not skip_compile:
56
+ with console.status("[bold blue]Running dbt compile...[/bold blue]"):
57
+ result = subprocess.run(
58
+ ["dbt", "compile", "--project-dir", str(project_path)],
59
+ capture_output=True,
60
+ text=True,
61
+ )
62
+ if result.returncode != 0:
63
+ console.print("[yellow]dbt compile failed — trying existing manifest.json[/yellow]")
64
+ console.print(f"[dim]{result.stderr[:500]}[/dim]")
65
+
66
+ # 2. Find manifest
67
+ try:
68
+ dbt_project = DbtProject(str(project_path))
69
+ manifest_path = dbt_project.find_manifest()
70
+ except FileNotFoundError as e:
71
+ console.print(f"[red]Error:[/red] {e}")
72
+ raise SystemExit(1)
73
+
74
+ # 3. Parse metrics
75
+ with console.status("[bold blue]Parsing metrics...[/bold blue]"):
76
+ manifest = DbtManifest(str(manifest_path))
77
+ metrics = manifest.get_metrics()
78
+
79
+ # Apply include/exclude
80
+ if config.include_metrics:
81
+ metrics = [m for m in metrics if m.name in config.include_metrics]
82
+ if config.exclude_metrics:
83
+ metrics = [m for m in metrics if m.name not in config.exclude_metrics]
84
+
85
+ if verbose:
86
+ for m in metrics:
87
+ console.print(
88
+ f" [green]✓[/green] [bold]{m.name}[/bold] "
89
+ f"({m.type}) — {m.description or m.label}"
90
+ )
91
+
92
+ # 4. Write manifest cache
93
+ output_dir = project_path / "dbt_agent_tools"
94
+ if not dry_run:
95
+ output_dir.mkdir(exist_ok=True)
96
+ cache = {
97
+ "generated_at": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
98
+ "manifest_format": manifest.metric_format,
99
+ "metrics": [
100
+ {
101
+ "name": m.name,
102
+ "label": m.label,
103
+ "description": m.description,
104
+ "type": m.type,
105
+ "sql": m.sql,
106
+ "measure": m.measure,
107
+ "model": m.model,
108
+ "dimensions": m.dimensions,
109
+ "timestamp_column": m.timestamp_column,
110
+ "filters": m.filters,
111
+ "meta": m.meta,
112
+ "warehouse_table": m.warehouse_table,
113
+ "format": m.format,
114
+ "currency": m.currency,
115
+ }
116
+ for m in metrics
117
+ ],
118
+ }
119
+ cache_path = output_dir / "manifest_cache.json"
120
+ cache_path.write_text(json.dumps(cache, indent=2))
121
+
122
+ # 5. Summary table
123
+ table = Table(title="Metrics discovered", show_lines=True)
124
+ table.add_column("Name", style="cyan")
125
+ table.add_column("Label")
126
+ table.add_column("Type", style="dim")
127
+ table.add_column("Dimensions", style="dim")
128
+
129
+ for m in metrics:
130
+ table.add_row(m.name, m.label, m.type, ", ".join(m.dimensions) or "—")
131
+
132
+ console.print(table)
133
+
134
+ action = "Would generate" if dry_run else "Generated"
135
+ console.print(
136
+ f"\n[bold green]{action}[/bold green] [bold]{len(metrics)}[/bold] metric tools"
137
+ + (f" → [cyan]{output_dir}[/cyan]" if not dry_run else "")
138
+ )
@@ -0,0 +1,74 @@
1
+ """dbt-agent init command."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ console = Console()
10
+
11
+
12
+ @click.command()
13
+ @click.option(
14
+ "--project-dir",
15
+ default=".",
16
+ show_default=True,
17
+ help="Path to dbt project directory.",
18
+ type=click.Path(exists=True, file_okay=False),
19
+ )
20
+ @click.option(
21
+ "--adapter",
22
+ default=None,
23
+ help="Warehouse adapter (postgres|bigquery|snowflake|duckdb). Auto-detected if not set.",
24
+ type=click.Choice(["postgres", "bigquery", "snowflake", "duckdb"], case_sensitive=False),
25
+ )
26
+ def init_cmd(project_dir: str, adapter: str | None) -> None:
27
+ """Initialise dbt-agent-layer in a dbt project."""
28
+ import yaml
29
+
30
+ from ..config.settings import write_default_config, _detect_adapter_from_profiles
31
+
32
+ project_path = Path(project_dir).expanduser().resolve()
33
+
34
+ # Detect dbt project
35
+ dbt_project_yml = project_path / "dbt_project.yml"
36
+ if not dbt_project_yml.exists():
37
+ console.print(
38
+ f"[red]Error:[/red] dbt_project.yml not found in {project_path}. "
39
+ "Are you inside a dbt project directory?"
40
+ )
41
+ raise SystemExit(1)
42
+
43
+ with open(dbt_project_yml) as f:
44
+ dbt_cfg = yaml.safe_load(f) or {}
45
+ project_name = dbt_cfg.get("name", "unknown")
46
+
47
+ # Auto-detect adapter from profiles.yml
48
+ detected_adapter = adapter
49
+ if not detected_adapter:
50
+ target = dbt_cfg.get("target", "dev")
51
+ for profiles_dir in [project_path, Path.home() / ".dbt"]:
52
+ profiles_path = profiles_dir / "profiles.yml"
53
+ if profiles_path.exists():
54
+ detected_adapter, _ = _detect_adapter_from_profiles(profiles_path, target)
55
+ break
56
+ detected_adapter = detected_adapter or "duckdb"
57
+
58
+ # Write config
59
+ config_path = write_default_config(str(project_path), adapter_type=detected_adapter)
60
+
61
+ console.print(
62
+ Panel(
63
+ f"[bold green]dbt-agent-layer initialised[/bold green]\n\n"
64
+ f" dbt project : [cyan]{project_name}[/cyan]\n"
65
+ f" adapter : [cyan]{detected_adapter}[/cyan]\n"
66
+ f" config : [cyan]{config_path}[/cyan]\n\n"
67
+ "[bold]Next steps:[/bold]\n"
68
+ " 1. Edit [cyan]dbt-agent.yml[/cyan] if needed\n"
69
+ " 2. Run [bold]dbt-agent build[/bold] to generate tools\n"
70
+ " 3. Run [bold]dbt-agent serve[/bold] to start the MCP server",
71
+ title="dbt-agent init",
72
+ border_style="green",
73
+ )
74
+ )
@@ -0,0 +1,18 @@
1
+ """Main CLI entrypoint — groups all dbt-agent commands."""
2
+
3
+ import click
4
+
5
+ from .init import init_cmd
6
+ from .build import build_cmd
7
+ from .serve import serve_cmd
8
+
9
+
10
+ @click.group()
11
+ @click.version_option(package_name="dbt-agent-layer")
12
+ def cli() -> None:
13
+ """dbt-agent-layer: Make your dbt metrics queryable by AI agents via MCP."""
14
+
15
+
16
+ cli.add_command(init_cmd, name="init")
17
+ cli.add_command(build_cmd, name="build")
18
+ cli.add_command(serve_cmd, name="serve")
@@ -0,0 +1,181 @@
1
+ """dbt-agent serve command."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ console = Console()
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @click.command()
17
+ @click.option(
18
+ "--project-dir",
19
+ default=".",
20
+ show_default=True,
21
+ help="Path to dbt project directory.",
22
+ type=click.Path(file_okay=False),
23
+ )
24
+ @click.option(
25
+ "--port",
26
+ default=None,
27
+ type=int,
28
+ help="Override port from dbt-agent.yml (default: 8000).",
29
+ )
30
+ @click.option(
31
+ "--transport",
32
+ default=None,
33
+ type=click.Choice(["stdio", "http"], case_sensitive=False),
34
+ help="MCP transport: stdio (Claude Desktop) or http.",
35
+ )
36
+ @click.option(
37
+ "--skip-build",
38
+ is_flag=True,
39
+ default=False,
40
+ help="Use existing ./dbt_agent_tools/; skip build step.",
41
+ )
42
+ @click.option(
43
+ "--reload",
44
+ is_flag=True,
45
+ default=False,
46
+ help="Hot-reload on dbt project changes (dev mode).",
47
+ )
48
+ def serve_cmd(
49
+ project_dir: str,
50
+ port: int | None,
51
+ transport: str | None,
52
+ skip_build: bool,
53
+ reload: bool,
54
+ ) -> None:
55
+ """Start the MCP server exposing all dbt metrics as callable tools."""
56
+ from ..config.settings import load_config
57
+ from ..executor.factory import get_executor
58
+ from ..parser.models import DbtMetric
59
+
60
+ project_path = Path(project_dir).expanduser().resolve()
61
+ config = load_config(str(project_path))
62
+
63
+ # Override config from CLI flags
64
+ if port is not None:
65
+ config.port = port
66
+ if transport is not None:
67
+ config.transport = transport
68
+
69
+ # --- Build step ---
70
+ metrics = _load_metrics(project_path, skip_build, config)
71
+
72
+ if not metrics:
73
+ console.print(
74
+ "[yellow]Warning:[/yellow] No metrics found. "
75
+ "Run [bold]dbt-agent build[/bold] or check your dbt project for metrics: blocks."
76
+ )
77
+
78
+ # --- Executor ---
79
+ try:
80
+ executor = get_executor(config)
81
+ except Exception as e:
82
+ console.print(f"[red]Error creating executor:[/red] {e}")
83
+ raise SystemExit(1)
84
+
85
+ # --- Test connection ---
86
+ async def _test():
87
+ return await executor.test_connection()
88
+
89
+ ok = asyncio.run(_test())
90
+ if not ok:
91
+ console.print(
92
+ f"[yellow]Warning:[/yellow] Could not connect to {config.adapter_type}. "
93
+ "Metric queries will fail until the connection is available."
94
+ )
95
+
96
+ # --- Create MCP server ---
97
+ from ..mcp.server import create_server
98
+
99
+ server = create_server(metrics, executor, config)
100
+
101
+ console.print(
102
+ Panel(
103
+ f"[bold green]dbt-agent-layer MCP server starting[/bold green]\n\n"
104
+ f" metrics : [cyan]{len(metrics)}[/cyan]\n"
105
+ f" adapter : [cyan]{config.adapter_type}[/cyan]\n"
106
+ f" transport : [cyan]{config.transport}[/cyan]\n"
107
+ + (f" port : [cyan]{config.port}[/cyan]\n" if config.transport == "http" else "")
108
+ + "\n[bold]Connect with Claude Desktop:[/bold]\n"
109
+ ' Add to claude_desktop_config.json → mcpServers → "dbt-metrics"',
110
+ title="dbt-agent serve",
111
+ border_style="blue",
112
+ )
113
+ )
114
+
115
+ # --- Start server ---
116
+ if config.transport == "http":
117
+ server.run(transport="streamable-http", host=config.host, port=config.port)
118
+ else:
119
+ server.run(transport="stdio")
120
+
121
+
122
+ def _load_metrics(
123
+ project_path: Path, skip_build: bool, config: "AgentConfig" # noqa: F821
124
+ ) -> list["DbtMetric"]: # noqa: F821
125
+ """Load metrics from manifest cache or by running the build step."""
126
+ from ..parser.models import DbtMetric
127
+
128
+ # Try cached manifest first if skip_build
129
+ cache_path = project_path / "dbt_agent_tools" / "manifest_cache.json"
130
+ if skip_build and cache_path.exists():
131
+ try:
132
+ with open(cache_path) as f:
133
+ cache = json.load(f)
134
+ metrics = []
135
+ for m in cache.get("metrics", []):
136
+ metrics.append(
137
+ DbtMetric(
138
+ name=m["name"],
139
+ label=m["label"],
140
+ description=m.get("description"),
141
+ type=m.get("type", "simple"),
142
+ sql=m.get("sql"),
143
+ measure=m.get("measure"),
144
+ model=m.get("model"),
145
+ dimensions=m.get("dimensions", []),
146
+ timestamp_column=m.get("timestamp_column"),
147
+ filters=m.get("filters", []),
148
+ meta=m.get("meta", {}),
149
+ warehouse_table=m.get("warehouse_table"),
150
+ format=m.get("format"),
151
+ currency=m.get("currency"),
152
+ )
153
+ )
154
+ console.print(
155
+ f"[dim]Loaded {len(metrics)} metrics from cache ({cache_path})[/dim]"
156
+ )
157
+ return metrics
158
+ except Exception as e:
159
+ console.print(f"[yellow]Cache load failed ({e}), rebuilding...[/yellow]")
160
+
161
+ # Build from manifest
162
+ from ..parser.project import DbtProject
163
+ from ..parser.manifest import DbtManifest
164
+
165
+ try:
166
+ dbt_project = DbtProject(str(project_path))
167
+ manifest_path = dbt_project.find_manifest()
168
+ except FileNotFoundError:
169
+ # Try schema file parsing as last resort
170
+ console.print(
171
+ "[yellow]manifest.json not found — scanning schema.yml files...[/yellow]"
172
+ )
173
+ from ..parser.semantic_layer import parse_schema_files
174
+ metrics = parse_schema_files(str(project_path))
175
+ return metrics
176
+
177
+ with console.status("[bold blue]Parsing metrics from manifest...[/bold blue]"):
178
+ manifest = DbtManifest(str(manifest_path))
179
+ metrics = manifest.get_metrics()
180
+
181
+ return metrics
@@ -0,0 +1,5 @@
1
+ """Config module."""
2
+
3
+ from .settings import AgentConfig, load_config
4
+
5
+ __all__ = ["AgentConfig", "load_config"]
@@ -0,0 +1,183 @@
1
+ """Configuration loading and management for dbt-agent-layer."""
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class AgentConfig:
15
+ """Full configuration for dbt-agent-layer."""
16
+
17
+ # dbt project
18
+ project_dir: str = "."
19
+ profiles_dir: str = "~/.dbt"
20
+ target: str = "dev"
21
+
22
+ # adapter
23
+ adapter_type: str = "duckdb"
24
+ adapter_kwargs: dict = field(default_factory=dict)
25
+
26
+ # server
27
+ host: str = "0.0.0.0"
28
+ port: int = 8000
29
+ transport: str = "stdio"
30
+
31
+ # metric behaviour
32
+ default_period: str = "current_month"
33
+ compare_to: str = "prior_period"
34
+ include_metrics: list[str] = field(default_factory=list)
35
+ exclude_metrics: list[str] = field(default_factory=list)
36
+
37
+ # narrative
38
+ narratives_enabled: bool = True
39
+ narrative_style: str = "concise"
40
+
41
+
42
+ def _read_yaml(path: Path) -> dict:
43
+ """Read a YAML file, returning empty dict on failure."""
44
+ try:
45
+ with open(path) as f:
46
+ return yaml.safe_load(f) or {}
47
+ except FileNotFoundError:
48
+ return {}
49
+ except yaml.YAMLError as e:
50
+ logger.warning("Failed to parse YAML at %s: %s", path, e)
51
+ return {}
52
+
53
+
54
+ def _detect_adapter_from_profiles(profiles_path: Path, target: str) -> tuple[str, dict]:
55
+ """
56
+ Read profiles.yml and extract adapter type + connection kwargs for a given target.
57
+ Returns ("duckdb", {}) as fallback.
58
+ """
59
+ profiles = _read_yaml(profiles_path)
60
+ for profile_name, profile in profiles.items():
61
+ if not isinstance(profile, dict):
62
+ continue
63
+ outputs = profile.get("outputs", {})
64
+ if target in outputs:
65
+ cfg = outputs[target]
66
+ adapter = cfg.get("type", "duckdb")
67
+ return adapter, cfg
68
+ # also check any output if target not found
69
+ for output_name, cfg in outputs.items():
70
+ if isinstance(cfg, dict):
71
+ adapter = cfg.get("type", "duckdb")
72
+ return adapter, cfg
73
+ return "duckdb", {}
74
+
75
+
76
+ def load_config(project_dir: str = ".") -> AgentConfig:
77
+ """
78
+ Load configuration with priority (highest to lowest):
79
+ 1. Environment variables (DBT_AGENT_*)
80
+ 2. dbt-agent.yml in project_dir
81
+ 3. Auto-detection from dbt_project.yml + profiles.yml
82
+ 4. Defaults
83
+
84
+ Environment variables:
85
+ DBT_AGENT_ADAPTER_TYPE
86
+ DBT_AGENT_PORT
87
+ DBT_AGENT_TRANSPORT
88
+ DBT_AGENT_TARGET
89
+ DBT_AGENT_PROFILES_DIR
90
+ """
91
+ project_path = Path(project_dir).expanduser().resolve()
92
+ config = AgentConfig(project_dir=str(project_path))
93
+
94
+ # --- Step 3: auto-detect from dbt_project.yml ---
95
+ dbt_project = _read_yaml(project_path / "dbt_project.yml")
96
+ if dbt_project:
97
+ config.target = dbt_project.get("target", config.target)
98
+
99
+ # --- Step 2: dbt-agent.yml ---
100
+ agent_yml = _read_yaml(project_path / "dbt-agent.yml")
101
+ if agent_yml:
102
+ proj = agent_yml.get("project", {})
103
+ config.project_dir = str(
104
+ (project_path / proj.get("dir", ".")).resolve()
105
+ )
106
+ config.profiles_dir = proj.get("profiles_dir", config.profiles_dir)
107
+ config.target = proj.get("target", config.target)
108
+
109
+ adapter = agent_yml.get("adapter", {})
110
+ if "type" in adapter:
111
+ config.adapter_type = adapter["type"]
112
+
113
+ server = agent_yml.get("server", {})
114
+ config.host = server.get("host", config.host)
115
+ config.port = int(server.get("port", config.port))
116
+ config.transport = server.get("transport", config.transport)
117
+
118
+ metrics = agent_yml.get("metrics", {})
119
+ config.include_metrics = metrics.get("include", config.include_metrics) or []
120
+ config.exclude_metrics = metrics.get("exclude", config.exclude_metrics) or []
121
+ config.default_period = metrics.get("default_period", config.default_period)
122
+ config.compare_to = metrics.get("compare_to", config.compare_to)
123
+
124
+ narratives = agent_yml.get("narratives", {})
125
+ config.narratives_enabled = narratives.get("enabled", config.narratives_enabled)
126
+ config.narrative_style = narratives.get("style", config.narrative_style)
127
+
128
+ # --- Auto-detect adapter from profiles.yml ---
129
+ profiles_dir = Path(config.profiles_dir).expanduser()
130
+ profiles_path = profiles_dir / "profiles.yml"
131
+ if profiles_path.exists():
132
+ detected_type, detected_kwargs = _detect_adapter_from_profiles(
133
+ profiles_path, config.target
134
+ )
135
+ if not agent_yml.get("adapter", {}).get("type"):
136
+ config.adapter_type = detected_type
137
+ config.adapter_kwargs = detected_kwargs
138
+
139
+ # --- Step 1: environment variables ---
140
+ if v := os.environ.get("DBT_AGENT_ADAPTER_TYPE"):
141
+ config.adapter_type = v
142
+ if v := os.environ.get("DBT_AGENT_PORT"):
143
+ config.port = int(v)
144
+ if v := os.environ.get("DBT_AGENT_TRANSPORT"):
145
+ config.transport = v
146
+ if v := os.environ.get("DBT_AGENT_TARGET"):
147
+ config.target = v
148
+ if v := os.environ.get("DBT_AGENT_PROFILES_DIR"):
149
+ config.profiles_dir = v
150
+
151
+ return config
152
+
153
+
154
+ def write_default_config(project_dir: str, adapter_type: str = "duckdb") -> Path:
155
+ """Write a default dbt-agent.yml to project_dir."""
156
+ config_path = Path(project_dir) / "dbt-agent.yml"
157
+ content = f"""version: 1
158
+
159
+ project:
160
+ dir: "." # path to dbt project
161
+ profiles_dir: "~/.dbt" # path to profiles.yml
162
+ target: "dev" # dbt target to use
163
+
164
+ adapter:
165
+ type: {adapter_type} # postgres | bigquery | snowflake | duckdb
166
+
167
+ server:
168
+ host: "0.0.0.0"
169
+ port: 8000
170
+ transport: "stdio" # stdio | http (stdio for Claude Desktop)
171
+
172
+ metrics:
173
+ include: [] # empty = all metrics
174
+ exclude: []
175
+ default_period: "current_month"
176
+ compare_to: "prior_period" # prior_period | prior_year | both
177
+
178
+ narratives:
179
+ enabled: true
180
+ style: "concise" # concise | detailed
181
+ """
182
+ config_path.write_text(content)
183
+ return config_path
@@ -0,0 +1,6 @@
1
+ """Executor module: SQL execution adapters."""
2
+
3
+ from .base import BaseExecutor
4
+ from .factory import get_executor
5
+
6
+ __all__ = ["BaseExecutor", "get_executor"]
@@ -0,0 +1,19 @@
1
+ """Abstract base class for SQL executors."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class BaseExecutor(ABC):
7
+ """Abstract interface for warehouse SQL execution."""
8
+
9
+ @abstractmethod
10
+ async def execute(self, sql: str) -> list[dict]:
11
+ """Execute SQL and return list of row dicts."""
12
+
13
+ @abstractmethod
14
+ async def test_connection(self) -> bool:
15
+ """Ping the warehouse. Used on startup."""
16
+
17
+ @abstractmethod
18
+ def get_adapter_type(self) -> str:
19
+ """Returns 'postgres' | 'bigquery' | 'snowflake' | 'duckdb'."""