From fa898eccff94eb5e01335f99a3b6c3ec1bae4d2b Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 11 Apr 2026 15:53:19 +0200 Subject: [PATCH 1/8] Refine index.md example query --- docs/index.md | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/index.md b/docs/index.md index 652b690..ecf78e1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,30 +16,36 @@ SLayer is different: models are editable at runtime, aggregation is chosen at qu ## What it looks like -Given an `orders` [model](concepts/models.md) with a `revenue` measure and a join to `customers`: +Given an `orders` [model](concepts/models.md) with a `revenue` measure and joins to `customers` and `regions`: ```json { "source_model": "orders", "fields": [ "revenue:sum", - {"formula": "revenue:sum / *:count", "name": "aov"}, {"formula": "change_pct(revenue:sum)", "name": "mom_growth"}, - {"formula": "cumsum(revenue:sum)", "name": "running_total"}, - "customers.score:avg" + {"formula": "revenue:sum / time_shift(revenue:sum, -1, 'year') - 1", "name": "yoy_growth"}, + "customers.score:last(changed_at)" ], - "time_dimensions": [{"dimension": "created_at", "granularity": "month"}], - "filters": ["status = 'completed'", "change(revenue:sum) > 0"] + "dimensions": ["customers.regions.name"], + "time_dimensions": [{ + "dimension": "created_at", + "granularity": "month", + "date_range": ["2025-01-01", "2025-12-31"] + }], + "filters": ["status = 'completed'", "change(revenue:sum) > 0"], + "order": [{"name": "revenue_sum", "direction": "desc"}] } ``` One query, and SLayer handles: -- **`revenue:sum`** — aggregation is chosen at query time, not baked into the measure definition. The same `revenue` measure works with `sum`, `avg`, `median`, `weighted_avg`, or [any custom aggregation](examples/07_aggregations/aggregations.md). No measure proliferation. -- **`revenue:sum / *:count`** — arithmetic on aggregated measures, named inline. -- **`change_pct(revenue:sum)`** — month-over-month growth, computed as a window transform. SLayer has [built-in transforms](examples/04_time/time.md) for `cumsum`, `change`, `time_shift`, `rank`, `lag`, `lead` — all nestable (`"change(cumsum(revenue:sum))"` works). -- **`customers.score:avg`** — a measure from a [joined model](examples/05_joined_measures/joined_measures.md), resolved automatically by walking the join graph. No manual sub-query needed. -- **`change(revenue:sum) > 0`** — filtering on a computed transform, right in the filter string. SLayer figures out it needs to compute the transform first, then filter. +- **`revenue:sum`** — aggregation is chosen at query time, not baked into the measure definition. The same `revenue` measure works with `sum`, `avg`, `median`, `weighted_avg`, or [any custom aggregation](examples/07_aggregations/aggregations.md). +- **`change_pct(revenue:sum)`** — month-over-month growth as a [transform](examples/04_time/time.md). SLayer generates the necessary window query. Other built-in transforms: `cumsum`, `change`, `time_shift`, `rank`, `lag`, `lead` — all nestable (`"change(cumsum(revenue:sum))"` works). +- **`revenue:sum / time_shift(revenue:sum, -1, 'year') - 1`** — arithmetic on aggregated measures. `time_shift` runs a separate time-shifted sub-query and joins it back by all dimensions; dividing by it gives year-over-year growth. Standard operator precedence applies. +- **`customers.score:last(changed_at)`** — a measure from a [joined model](examples/05_joined_measures/joined_measures.md), resolved by walking the [join graph](examples/05_joins/joins.md). `last` is an aggregation that picks the latest record's value — `changed_at` tells it which column defines "latest." +- **`customers.regions.name`** — a multi-hop dimension: SLayer traces `orders → customers → regions` and builds the joins automatically. +- **`change(revenue:sum) > 0`** — filtering on a computed transform. SLayer computes the transform first as a hidden field, then applies the filter on the outer query. ## What SLayer does From d3bbd582d5cef19e27fb0ebdaae5217d6ca750f5 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 11 Apr 2026 16:29:49 +0200 Subject: [PATCH 2/8] Docs: uvx install-free setup, YAML datasource config; graceful error handling for invalid datasources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs: - MCP getting-started now leads with uvx (no install needed) and JSON config - Replaced conversational DB setup with the recommended path: drop a YAML datasource config with ${ENV_VAR} references into the storage folder - Noted that datasource configs are hot-reloaded (no restart needed) - Removed SSE details from getting-started (link to reference instead) - MCP reference now has a Quick Start section with uvx examples - Moved datasource_summary and ingest_datasource_models to Datasource Management section in reference Error handling: - resolve_env_vars() now detects unresolved ${VAR} references and raises a clear ValueError listing the missing variables - yaml_storage.get_datasource() catches YAML parse errors and Pydantic validation errors, re-raises as descriptive ValueErrors - MCP tools (list_datasources, datasource_summary, describe_datasource, list_tables) catch per-datasource errors so one bad config doesn't break the whole list — errors are reported inline Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/getting-started/mcp.md | 95 +++++++++++++++++++++------------- docs/reference/mcp.md | 57 ++++++++++---------- slayer/core/models.py | 11 +++- slayer/mcp/server.py | 36 ++++++++----- slayer/storage/yaml_storage.py | 18 +++++-- tests/test_mcp_server.py | 32 ++++++++++++ tests/test_storage.py | 30 +++++++++++ 7 files changed, 200 insertions(+), 79 deletions(-) diff --git a/docs/getting-started/mcp.md b/docs/getting-started/mcp.md index 1d6154e..cc292f6 100644 --- a/docs/getting-started/mcp.md +++ b/docs/getting-started/mcp.md @@ -2,65 +2,81 @@ Connect your AI agent (Claude Code, Cursor, etc.) to your database through SLayer's MCP server. No Python knowledge required. -## Install +## Prerequisites + +Install [uv](https://docs.astral.sh/uv/getting-started/installation/) — the fast Python package manager. SLayer runs via `uvx` with no separate install step. + +## Connect to your agent + +### Claude Code + +Register SLayer as an MCP server — Claude Code will spawn it automatically when needed: ```bash -uv tool install motley-slayer +claude mcp add slayer -- uvx --from 'motley-slayer[mcp]' slayer mcp --storage ./slayer_data ``` For databases other than SQLite, add the driver extra (see [full list](../configuration/datasources.md#database-drivers)): ```bash -uv tool install 'motley-slayer[postgres]' +claude mcp add slayer -- uvx --from 'motley-slayer[mcp,postgres]' slayer mcp --storage ./slayer_data ``` -## Connect to your agent +### Other agents (JSON config) -### Claude Code (stdio — recommended) +Most MCP-compatible agents accept a JSON server configuration. Add this to your agent's MCP config file: -```bash -claude mcp add slayer -- slayer mcp --storage ./slayer_data +```json +{ + "mcpServers": { + "slayer": { + "command": "uvx", + "args": ["--from", "motley-slayer[mcp,postgres]", "slayer", "mcp", "--storage", "./slayer_data"] + } + } +} ``` -If SLayer is in a virtualenv, use the full path to the executable: +Replace `postgres` with your database driver, or use `motley-slayer[all]` for all supported databases. + +### Remote / shared server + +SLayer also supports HTTP/SSE transport for running on a different machine, in Docker, or sharing between multiple agents. See the [MCP Reference](../reference/mcp.md#sse-remote) for details. + +### Verify ```bash -claude mcp add slayer -- $(which slayer) mcp --storage /absolute/path/to/slayer_data +claude mcp list ``` -### Remote agents (HTTP/SSE) +## Connect a database -Start the server, then point your agent at the SSE endpoint: +The recommended approach is to drop a datasource YAML file into your storage folder. This keeps credentials out of the agent conversation and lets you use environment variable references. -```bash -slayer serve --storage ./slayer_data +Create a file at `slayer_data/datasources/mydb.yaml`: -# In another terminal / agent config: -claude mcp add slayer-remote --transport sse --url http://localhost:5143/mcp/sse +```yaml +name: mydb +type: postgres +host: ${DB_HOST} +port: 5432 +database: ${DB_NAME} +username: ${DB_USER} +password: ${DB_PASSWORD} +schema_name: public ``` -## Connect a database +`${...}` references are resolved from environment variables at read time. Set them in your shell before starting the agent, or use a `.env` file with your agent's environment configuration. -Once the agent is connected, it handles everything conversationally. A typical exchange: +Datasource configs are **hot-reloaded** — you can add or edit YAML files while the server is running, and the next MCP tool call will pick up the changes. No restart needed. -> **You:** Connect to my Postgres database at localhost, database "myapp", user "analyst" -> -> **Agent:** *calls `create_datasource` → auto-ingests models → calls `datasource_summary`* -> -> "Connected! I found 4 tables: orders (12 dims, 8 measures), customers (5 dims, 3 measures), ..." -> -> **You:** How many orders per status? -> -> **Agent:** *calls `query(source_model="orders", fields=["*:count"], dimensions=["status"])`* +Once the datasource file is in place, ask your agent: -The agent uses these MCP tools in order: +> "Ingest models from the mydb datasource and show me what's available" -1. `create_datasource` — connect to DB (auto-ingests models by default) -2. `datasource_summary` — discover available models and their schemas -3. `inspect_model` — see dimensions, measures, and sample data for a model -4. `query` — run queries +The agent will call `ingest_datasource_models` to generate models from the database schema, then `datasource_summary` to list them. -See the [MCP Reference](../reference/mcp.md) for the full tools list. +You can also create datasources conversationally via the `create_datasource` MCP tool — see the [MCP Reference](../reference/mcp.md#datasource-management) for details. ## Verify it works @@ -70,6 +86,15 @@ Ask your agent: The agent should call `datasource_summary` and return a list of your tables/models. If it says "no models found", check that: -1. The `--storage` path is correct -2. You've connected a datasource (or the agent has via `create_datasource`) -3. Models were ingested (auto-ingest runs by default with `create_datasource`) +1. The `--storage` path matches where your datasource YAML files are +2. Models have been ingested (via `ingest_datasource_models` or `create_datasource` with auto-ingest) +3. Environment variables referenced in the datasource config are set + +## Alternative: permanent install + +If you prefer a traditional install instead of `uvx`: + +```bash +uv tool install 'motley-slayer[mcp,postgres]' +claude mcp add slayer -- slayer mcp --storage ./slayer_data +``` diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index b9ebe7e..6921eb7 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -2,46 +2,54 @@ SLayer runs as an [MCP](https://modelcontextprotocol.io/) server, allowing AI agents (Claude, Cursor, etc.) to discover and query data conversationally. -## Transports - -SLayer supports two MCP transports. Both expose the exact same tools — the only difference is how the agent connects. - -### Stdio (local) +## Quick Start -The agent spawns SLayer as a subprocess and communicates via stdin/stdout. You do **not** run `slayer mcp` manually — the agent launches it. You only need to register the command with your agent. +The fastest way to run SLayer is via `uvx` — no install needed. You only need [uv](https://docs.astral.sh/uv/getting-started/installation/). -**Claude Code setup:** +**Claude Code:** ```bash -claude mcp add slayer -- slayer mcp --models-dir ./slayer_data +claude mcp add slayer -- uvx --from 'motley-slayer[mcp,postgres]' slayer mcp --storage ./slayer_data ``` -If `slayer` is installed in a virtualenv (e.g. via Poetry), use the full path to the executable so the agent can find it regardless of working directory: +**JSON config** (Claude Desktop, Cursor, and other MCP-compatible agents): + +```json +{ + "mcpServers": { + "slayer": { + "command": "uvx", + "args": ["--from", "motley-slayer[mcp,postgres]", "slayer", "mcp", "--storage", "./slayer_data"] + } + } +} +``` -```bash -# Find the virtualenv path -poetry env info -p -# e.g. /home/user/.venvs/slayer-abc123 +Replace `postgres` with your database driver (see [full list](../configuration/datasources.md#database-drivers)), or use `motley-slayer[all]` for all supported databases. SQLite works with just `motley-slayer[mcp]`. -# Register with the full path -claude mcp add slayer -- /home/user/.venvs/slayer-abc123/bin/slayer mcp --models-dir /path/to/slayer_data -``` +See the [Getting Started guide](../getting-started/mcp.md) for full setup instructions including SSE/remote and permanent install options. + +## Transports + +SLayer supports two MCP transports. Both expose the exact same tools. + +### Stdio (local — recommended) + +The agent spawns SLayer as a subprocess and communicates via stdin/stdout. You do **not** run `slayer mcp` manually — the agent launches it. The `claude mcp add` and JSON config examples above both use this transport. ### SSE (remote) MCP over HTTP via Server-Sent Events. You run `slayer serve` yourself — it exposes both the REST API and the MCP SSE endpoint on the same port: ```bash -# 1. Start the server -slayer serve --models-dir ./slayer_data +uvx --from 'motley-slayer[mcp,postgres]' slayer serve --storage ./slayer_data # REST API at http://localhost:5143/ # MCP SSE at http://localhost:5143/mcp/sse ``` -Then, in a separate terminal, register the remote endpoint with your agent: +Then register the remote endpoint with your agent: ```bash -# 2. Connect the agent claude mcp add slayer-remote --transport sse --url http://localhost:5143/mcp/sse ``` @@ -65,12 +73,13 @@ claude mcp list | `list_tables` | List tables in a database before ingesting. | | `edit_datasource` | Edit an existing datasource config. | | `delete_datasource` | Remove a datasource config. | +| `datasource_summary` | List all datasources and their models with schemas (dimensions, measures). Returns JSON. | +| `ingest_datasource_models` | Auto-generate models from DB schema with rollup joins. Params: `datasource_name`, `include_tables`, `schema_name`. | ### Model Management | Tool | Description | |------|-------------| -| `datasource_summary` | List all datasources and their models with schemas (dimensions, measures). Returns JSON. | | `inspect_model` | Detailed model info with sample data. Params: `model_name`, `num_rows` (default 3), `show_sql` (default false). | | `create_model` | Create a new model from table/SQL definition. | | `create_model_from_query` | Create a model from a query — saves the query's SQL as a reusable model with auto-introspected dimensions and measures. Params: `name`, `query` (SLayer query dict), `description` (optional). | @@ -101,12 +110,6 @@ claude mcp list | `explain` | bool | Run EXPLAIN ANALYZE and return the query plan | | `format` | string | Output format: `"markdown"` (default, compact), `"json"` (structured), or `"csv"` (most compact). Case-insensitive | -### Ingestion - -| Tool | Description | -|------|-------------| -| `ingest_datasource_models` | Auto-generate models from DB schema with rollup joins. Params: `datasource_name`, `include_tables`, `schema_name`. | - ## Typical Agent Workflows ### Connect and explore a new database diff --git a/slayer/core/models.py b/slayer/core/models.py index 907ae10..69886a1 100644 --- a/slayer/core/models.py +++ b/slayer/core/models.py @@ -287,9 +287,18 @@ def get_connection_string(self) -> str: def resolve_env_vars(self) -> "DatasourceConfig": data = self.model_dump() + unresolved = [] for key, value in data.items(): if isinstance(value, str): - data[key] = _resolve_env_string(value) + resolved = _resolve_env_string(value) + data[key] = resolved + for match in re.finditer(r"\$\{(\w+)\}", resolved): + unresolved.append(match.group(1)) + if unresolved: + raise ValueError( + f"Datasource '{self.name}': unresolved environment variable(s): " + f"{', '.join(unresolved)}" + ) return DatasourceConfig(**data) diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index df5a74a..65552a6 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -204,12 +204,15 @@ def datasource_summary() -> str: ds_names = storage.list_datasources() datasources = [] for name in ds_names: - ds = storage.get_datasource(name) - if ds: - entry = {"name": name, "type": ds.type} - if ds.description: - entry["description"] = ds.description - datasources.append(entry) + try: + ds = storage.get_datasource(name) + if ds: + entry: Dict[str, Any] = {"name": name, "type": ds.type} + if ds.description: + entry["description"] = ds.description + datasources.append(entry) + except (ValueError, Exception) as exc: + datasources.append({"name": name, "error": str(exc)}) # Models model_names = storage.list_models() @@ -530,9 +533,12 @@ def list_datasources() -> str: return "No datasources configured. Use create_datasource to add a database connection." lines = [] for name in names: - ds = storage.get_datasource(name) - ds_type = ds.type if ds else "unknown" - lines.append(f"- {name} ({ds_type})") + try: + ds = storage.get_datasource(name) + ds_type = ds.type if ds else "unknown" + lines.append(f"- {name} ({ds_type})") + except (ValueError, Exception) as exc: + lines.append(f"- {name} (ERROR: {exc})") return "\n".join(lines) @mcp.tool() @@ -542,7 +548,10 @@ def describe_datasource(name: str) -> str: Args: name: Datasource name (from list_datasources). """ - ds = storage.get_datasource(name) + try: + ds = storage.get_datasource(name) + except (ValueError, Exception) as exc: + return f"Datasource '{name}' has an invalid config: {exc}" if ds is None: return f"Datasource '{name}' not found." @@ -580,11 +589,14 @@ def list_tables(datasource_name: str, schema_name: str = "") -> str: datasource_name: Name of an existing datasource (from list_datasources). schema_name: Database schema (e.g. "public"). If empty, uses the default schema. """ - ds = storage.get_datasource(datasource_name) + try: + ds = storage.get_datasource(datasource_name) + except (ValueError, Exception) as exc: + return f"Datasource '{datasource_name}' has an invalid config: {exc}" if ds is None: return f"Datasource '{datasource_name}' not found." try: - conn_str = ds.resolve_env_vars().get_connection_string() + conn_str = ds.get_connection_string() sa_engine = sa.create_engine(conn_str) inspector = sa.inspect(sa_engine) schema = schema_name or None diff --git a/slayer/storage/yaml_storage.py b/slayer/storage/yaml_storage.py index d522f2a..f1b3bae 100644 --- a/slayer/storage/yaml_storage.py +++ b/slayer/storage/yaml_storage.py @@ -4,6 +4,7 @@ from typing import List, Optional import yaml +from pydantic import ValidationError from slayer.core.models import DatasourceConfig, SlayerModel from slayer.storage.base import StorageBackend @@ -55,10 +56,19 @@ def get_datasource(self, name: str) -> Optional[DatasourceConfig]: path = os.path.join(self.datasources_dir, f"{name}.yaml") if not os.path.exists(path): return None - with open(path) as f: - data = yaml.safe_load(f) - ds = DatasourceConfig.model_validate(data) - return ds.resolve_env_vars() + try: + with open(path) as f: + data = yaml.safe_load(f) + ds = DatasourceConfig.model_validate(data) + return ds.resolve_env_vars() + except yaml.YAMLError as exc: + raise ValueError( + f"Datasource '{name}': invalid YAML in {path} — {exc}" + ) from exc + except ValidationError as exc: + raise ValueError( + f"Datasource '{name}': invalid config — {exc}" + ) from exc def list_datasources(self) -> List[str]: result = [] diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 27c31a7..d700a3a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -2,6 +2,7 @@ import asyncio import json +import os import tempfile from typing import Any @@ -282,6 +283,37 @@ def test_create_reports_replaced(self, mcp_server, storage: YAMLStorage) -> None result = _call(mcp_server, "create_datasource", {"name": "ds", "type": "sqlite", "database": ":memory:"}) assert "replaced" in result + def test_list_with_malformed_datasource(self, mcp_server, storage: YAMLStorage) -> None: + # A valid datasource alongside a malformed one + storage.save_datasource(DatasourceConfig(name="good", type="sqlite", database=":memory:")) + path = os.path.join(storage.datasources_dir, "bad.yaml") + with open(path, "w") as f: + f.write("name: bad\ntype: [unclosed\n") + result = _call(mcp_server, "list_datasources") + assert "good (sqlite)" in result + assert "bad" in result + assert "ERROR" in result + + def test_summary_with_malformed_datasource(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_datasource(DatasourceConfig(name="good", type="sqlite", database=":memory:")) + path = os.path.join(storage.datasources_dir, "bad.yaml") + with open(path, "w") as f: + f.write("name: bad\ntype: [unclosed\n") + result = _call(mcp_server, "datasource_summary") + data = json.loads(result) + names = [d["name"] for d in data["datasources"]] + assert "good" in names + assert "bad" in names + bad_entry = next(d for d in data["datasources"] if d["name"] == "bad") + assert "error" in bad_entry + + def test_describe_malformed_datasource(self, mcp_server, storage: YAMLStorage) -> None: + path = os.path.join(storage.datasources_dir, "bad.yaml") + with open(path, "w") as f: + f.write("name: bad\ntype: [unclosed\n") + result = _call(mcp_server, "describe_datasource", {"name": "bad"}) + assert "invalid" in result.lower() + def test_describe_not_found(self, mcp_server) -> None: result = _call(mcp_server, "describe_datasource", {"name": "nope"}) assert "not found" in result diff --git a/tests/test_storage.py b/tests/test_storage.py index f1b259e..626c097 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,5 +1,6 @@ """Tests for YAML storage.""" +import os import tempfile import pytest @@ -101,3 +102,32 @@ def test_env_var_resolution(self, storage: YAMLStorage, monkeypatch: pytest.Monk storage.save_datasource(ds) loaded = storage.get_datasource("env_ds") assert loaded.host == "resolved-host" + + def test_malformed_yaml_raises_valueerror(self, storage: YAMLStorage) -> None: + path = os.path.join(storage.datasources_dir, "bad.yaml") + with open(path, "w") as f: + f.write("name: bad\ntype: [unclosed\n") + with pytest.raises(ValueError, match="Datasource 'bad': invalid YAML"): + storage.get_datasource("bad") + + def test_invalid_config_raises_valueerror(self, storage: YAMLStorage) -> None: + path = os.path.join(storage.datasources_dir, "bad_type.yaml") + with open(path, "w") as f: + f.write("name: bad_type\nport: not_a_number\n") + with pytest.raises(ValueError, match="Datasource 'bad_type': invalid config"): + storage.get_datasource("bad_type") + + def test_unresolved_env_var_raises_valueerror(self, storage: YAMLStorage) -> None: + ds = DatasourceConfig( + name="missing_env", type="postgres", host="${NONEXISTENT_VAR_12345}" + ) + storage.save_datasource(ds) + with pytest.raises(ValueError, match="unresolved environment variable"): + storage.get_datasource("missing_env") + + def test_malformed_datasource_does_not_break_list(self, storage: YAMLStorage) -> None: + path = os.path.join(storage.datasources_dir, "bad.yaml") + with open(path, "w") as f: + f.write("name: bad\ntype: [unclosed\n") + names = storage.list_datasources() + assert "bad" in names From ef54b14018d718533dc2b9d0a01b9773cfe134ff Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sat, 11 Apr 2026 17:05:39 +0200 Subject: [PATCH 3/8] Merge create_model_from_query into create_model MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create_model tool now accepts an optional `query` parameter (a SLayer query dict). When provided, it delegates to engine.create_model_from_query() and auto-introspects dimensions and measures from the query result. Mutually exclusive with sql_table/sql/dimensions/measures — clear error if both are supplied. This reduces the MCP tool surface from two tools to one without losing any functionality. The engine-level create_model_from_query() method is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/concepts/models.md | 2 +- .../02_sql_vs_dsl/sql_vs_dsl_nb.ipynb | 10 +-- docs/examples/07_aggregations/aggregations.md | 2 +- docs/interfaces/mcp.md | 3 +- docs/reference/mcp.md | 3 +- slayer/mcp/server.py | 73 +++++++++++-------- tests/test_mcp_server.py | 24 ++++++ 7 files changed, 70 insertions(+), 47 deletions(-) diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 1cd4206..3525f79 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -187,7 +187,7 @@ engine.create_model_from_query( The saved model can then be queried by name like any other model — useful for materializing complex aggregations. -Via MCP, use the `create_model_from_query` tool. Via API, `POST /models/from_query`. +Via MCP, use `create_model` with a `query` parameter. ### Column naming in query-derived models diff --git a/docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb b/docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb index 5035869..1baff3f 100644 --- a/docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb +++ b/docs/examples/02_sql_vs_dsl/sql_vs_dsl_nb.ipynb @@ -178,15 +178,7 @@ "cell_type": "markdown", "id": "8150b965", "metadata": {}, - "source": [ - "## Query Result as Model\n", - "\n", - "What if you want to use DSL transforms like `time_shift` or `change` to define derived measures or dimensions?\n", - "\n", - "Use `create_model_from_query()` to save a query's result as a permanent model. The derived columns become dimensions and measures on the new model, which can then be queried like any other - or just use a query inside another query, as covered in [multistage queries](../06_multistage_queries/multistage_queries.md)\n", - "\n", - "See [Creating Models from Queries](../../concepts/models.md#creating-models-from-queries) for details." - ] + "source": "## Query Result as Model\n\nWhat if you want to use DSL transforms like `time_shift` or `change` to define derived measures or dimensions?\n\nUse `create_model()` with a `query` parameter to save a query's result as a permanent model. The derived columns become dimensions and measures on the new model, which can then be queried like any other - or just use a query inside another query, as covered in [multistage queries](../06_multistage_queries/multistage_queries.md)\n\nSee [Creating Models from Queries](../../concepts/models.md#creating-models-from-queries) for details." }, { "cell_type": "markdown", diff --git a/docs/examples/07_aggregations/aggregations.md b/docs/examples/07_aggregations/aggregations.md index f2153a6..65fbd3f 100644 --- a/docs/examples/07_aggregations/aggregations.md +++ b/docs/examples/07_aggregations/aggregations.md @@ -182,7 +182,7 @@ The colon becomes an underscore in result keys: | `revenue:avg` | `orders.revenue_avg` | | `customers.*:count` | `orders.customers._count` | -When a query is saved as a model (`create_model_from_query`), these canonical names become the new model's column names. +When a query is saved as a model (`create_model` with a `query` parameter), these canonical names become the new model's column names. --- diff --git a/docs/interfaces/mcp.md b/docs/interfaces/mcp.md index a60077f..1a63f22 100644 --- a/docs/interfaces/mcp.md +++ b/docs/interfaces/mcp.md @@ -72,8 +72,7 @@ claude mcp list |------|-------------| | `datasource_summary` | List all datasources and their models with schemas (dimensions, measures). Returns JSON. | | `inspect_model` | Detailed model info with sample data. Params: `model_name`, `num_rows` (default 3), `show_sql` (default false). | -| `create_model` | Create a new model from table/SQL definition. | -| `create_model_from_query` | Create a model from a query — saves the query's SQL as a reusable model with auto-introspected dimensions and measures. Params: `name`, `query` (SLayer query dict), `description` (optional). | +| `create_model` | Create a model from a table/SQL definition or from a query. Pass `sql_table`/`sql` with `dimensions`/`measures` for table-based, or pass `query` (a SLayer query dict) to auto-introspect dimensions and measures from the query result. | | `edit_model` | Edit an existing model in one call. Params: `model_name` (required), `description`, `data_source`, `default_time_dimension` (optional metadata), `add_measures` (list), `add_dimensions` (list), `remove` (list of names). | | `delete_model` | Delete a model entirely. | diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 6921eb7..3ce4e80 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -81,8 +81,7 @@ claude mcp list | Tool | Description | |------|-------------| | `inspect_model` | Detailed model info with sample data. Params: `model_name`, `num_rows` (default 3), `show_sql` (default false). | -| `create_model` | Create a new model from table/SQL definition. | -| `create_model_from_query` | Create a model from a query — saves the query's SQL as a reusable model with auto-introspected dimensions and measures. Params: `name`, `query` (SLayer query dict), `description` (optional). | +| `create_model` | Create a model from a table/SQL definition or from a query. Pass `sql_table`/`sql` with `dimensions`/`measures` for table-based, or pass `query` (a SLayer query dict) to auto-introspect dimensions and measures from the query result. | | `edit_model` | Edit an existing model in one call. Params: `model_name` (required), `description`, `data_source`, `default_time_dimension` (optional metadata), `add_measures` (list), `add_dimensions` (list), `remove` (list of names). | | `delete_model` | Delete a model entirely. | diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index 65552a6..fe03b12 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -294,8 +294,19 @@ def create_model( description: Optional[str] = None, dimensions: Optional[List[Dict[str, str]]] = None, measures: Optional[List[Dict[str, Union[str, List[str]]]]] = None, + query: Optional[Dict] = None, ) -> str: - """Create a new semantic model that maps to a database table. + """Create a new semantic model, either from a database table or from a query. + + **From a table** (provide sql_table or sql): + create_model(name="orders", sql_table="public.orders", data_source="mydb", + dimensions=[...], measures=[...]) + + **From a query** (provide query): + create_model(name="monthly_summary", query={"source_model": "orders", + "fields": ["*:count", "amount:sum"], + "time_dimensions": [{"dimension": "created_at", "granularity": "month"}]}) + Dimensions and measures are auto-introspected from the query result. Args: name: Unique model name (lowercase, underscores). @@ -305,10 +316,37 @@ def create_model( description: What this model represents. dimensions: List of dimension definitions. Each: {"name": "col", "sql": "col", "type": "string"}. Types: string, number, time, date, boolean. - measures: List of measure definitions. Each: {"name": "total", "sql": "amount", "type": "sum"}. - Types: count, count_distinct, sum, avg, min, max. + measures: List of measure definitions. Each: {"name": "total", "sql": "amount"}. Optional: "allowed_aggregations": ["sum", "avg"] to restrict usable aggregations. + query: A SLayer query dict. When provided, the query's SQL becomes the model source + and dimensions/measures are auto-introspected. Mutually exclusive with + sql_table, sql, dimensions, and measures. """ + if query is not None: + table_params = _build_dict( + sql_table=sql_table, sql=sql, dimensions=dimensions, measures=measures, + ) + if table_params: + return ( + f"Error: 'query' cannot be combined with {', '.join(table_params.keys())}. " + "Use 'query' alone to create from a query, or provide table details without 'query'." + ) + try: + parsed_query = SlayerQuery.model_validate(query) + model = engine.create_model_from_query( + query=parsed_query, name=name, description=description, + ) + except Exception as e: + if isinstance(e, (sa.exc.OperationalError, sa.exc.DatabaseError)): + return _friendly_db_error(e) + return f"Error creating model from query: {e}" + dims = [d.name for d in model.dimensions] + meas = [m.name for m in model.measures] + return ( + f"Model '{name}' created from query. " + f"Dimensions: {dims}. Measures: {meas}." + ) + data = _build_dict( name=name, sql_table=sql_table, sql=sql, data_source=data_source, description=description, dimensions=dimensions, measures=measures, @@ -319,35 +357,6 @@ def create_model( verb = "replaced" if existed else "created" return f"Model '{model.name}' {verb}." - @mcp.tool() - def create_model_from_query( - name: str, - query: Dict, - description: Optional[str] = None, - ) -> str: - """Create a model from a query — saves the query's SQL as a reusable model. - - This lets you build complex queries (with transforms, filters, time dimensions) - and save the result as a permanent model that can be queried like any other. - - Args: - name: Name for the new model (lowercase, underscores). - query: A SLayer query dict, e.g. {"source_model": "orders", "fields": [{"formula": "count"}], - "time_dimensions": [{"dimension": {"name": "created_at"}, "granularity": "month"}]}. - description: What this derived model represents. - """ - from slayer.core.query import SlayerQuery as SQ - parsed_query = SQ.model_validate(query) - model = engine.create_model_from_query( - query=parsed_query, name=name, description=description, - ) - dims = [d.name for d in model.dimensions] - measures = [m.name for m in model.measures] - return ( - f"Model '{name}' created from query. " - f"Dimensions: {dims}. Measures: {measures}." - ) - @mcp.tool() def edit_model( model_name: str, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d700a3a..30be119 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -139,6 +139,30 @@ def test_create_reports_replaced(self, mcp_server, storage: YAMLStorage) -> None result = _call(mcp_server, "create_model", {"name": "orders", "sql_table": "t2", "data_source": "test"}) assert "replaced" in result + def test_create_from_query_rejects_mixed_params(self, mcp_server) -> None: + result = _call(mcp_server, "create_model", { + "name": "bad", + "query": {"source_model": "orders", "fields": ["*:count"]}, + "sql_table": "public.orders", + }) + assert "Error" in result + assert "query" in result + assert "sql_table" in result + + def test_create_from_query_routes_to_engine(self, mcp_server, storage: YAMLStorage) -> None: + # Without a real datasource/data, the engine will return a friendly error — + # but the error message proves we routed to the query path. + storage.save_model(SlayerModel( + name="orders", sql_table="orders", data_source="test_ds", + measures=[Measure(name="amount", sql="amount")], + )) + result = _call(mcp_server, "create_model", { + "name": "summary", + "query": {"source_model": "orders", "fields": ["amount:sum"]}, + }) + # Should fail on missing datasource, not on "missing sql_table" + assert "Datasource" in result + class TestEditModel: def test_add_measure(self, mcp_server, storage: YAMLStorage) -> None: From 65894051f0e1541223ee574932e9f0cbefc772ea Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 13 Apr 2026 09:55:59 +0200 Subject: [PATCH 4/8] Redesign edit_model MCP tool with upsert semantics and full entity coverage Replace the limited add-only edit_model with a comprehensive version that supports creating, updating, and deleting all model entities (dimensions, measures, aggregations, joins, filters) in a single call. Key changes: - Upsert semantics: dimensions/measures/aggregations/joins matched by name (or target_model for joins); existing entities get partial updates, new ones are created - Structured remove dict keyed by entity type replaces ambiguous flat list - Filter management via add_filters/remove_filters params - New scalar params: sql_table, sql, hidden - Full model re-validation before save catches cross-field errors - 30 tests (rewrote 9, added 21) covering upsert, partial update, aggregations, joins, filters, typed remove, and validation Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/reference/mcp.md | 10 +- slayer/mcp/server.py | 231 ++++++++++++++++++++-------- tests/test_mcp_server.py | 315 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 474 insertions(+), 82 deletions(-) diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 3ce4e80..fff6c05 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -82,7 +82,7 @@ claude mcp list |------|-------------| | `inspect_model` | Detailed model info with sample data. Params: `model_name`, `num_rows` (default 3), `show_sql` (default false). | | `create_model` | Create a model from a table/SQL definition or from a query. Pass `sql_table`/`sql` with `dimensions`/`measures` for table-based, or pass `query` (a SLayer query dict) to auto-introspect dimensions and measures from the query result. | -| `edit_model` | Edit an existing model in one call. Params: `model_name` (required), `description`, `data_source`, `default_time_dimension` (optional metadata), `add_measures` (list), `add_dimensions` (list), `remove` (list of names). | +| `edit_model` | Edit an existing model in one call. Supports upsert for dimensions, measures, aggregations, and joins (create if new, update if existing). Also manages scalar metadata and filters. See params below. | | `delete_model` | Delete a model entirely. | ### Querying @@ -143,8 +143,10 @@ To explore first without auto-ingesting: ``` 1. edit_model( model_name="orders", - add_measures=[{"name": "avg_amount", "sql": "amount", "type": "avg"}], - add_dimensions=[{"name": "priority", "sql": "priority", "type": "string"}], - remove=["amount_sum"] + measures=[{"name": "avg_amount", "sql": "amount"}], + dimensions=[{"name": "priority", "sql": "priority", "type": "string"}], + remove={"measures": ["amount_sum"]} ) ``` + +Upsert semantics: if a measure/dimension/aggregation/join with that name already exists, only the provided fields are updated. To remove entities, use the `remove` dict keyed by type (`"dimensions"`, `"measures"`, `"aggregations"`, `"joins"`). diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index fe03b12..4a75533 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -6,7 +6,14 @@ import sqlalchemy as sa -from slayer.core.models import DatasourceConfig, Dimension, Measure, SlayerModel +from slayer.core.models import ( + Aggregation, + DatasourceConfig, + Dimension, + Measure, + ModelJoin, + SlayerModel, +) from slayer.core.query import SlayerQuery from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.base import StorageBackend @@ -357,37 +364,99 @@ def create_model( verb = "replaced" if existed else "created" return f"Model '{model.name}' {verb}." + def _upsert_entity( + entity_list: list, + spec: dict, + entity_cls: type, + id_field: str, + changes: list, + label: str, + ) -> Optional[str]: + """Upsert a named entity in *entity_list*. + + Returns an error string on validation failure, ``None`` on success. + """ + entity_id = spec.get(id_field, "") + if not entity_id: + return f"Missing '{id_field}' in {label} specification." + + existing = next((e for e in entity_list if getattr(e, id_field) == entity_id), None) + if existing is not None: + merged = existing.model_dump() + for k, v in spec.items(): + merged[k] = v + try: + updated = entity_cls.model_validate(merged) + except Exception as exc: + return f"Invalid {label} '{entity_id}': {exc}" + idx = entity_list.index(existing) + entity_list[idx] = updated + changes.append(f"updated {label} '{entity_id}'") + else: + try: + new_entity = entity_cls.model_validate(spec) + except Exception as exc: + return f"Invalid {label} '{entity_id}': {exc}" + entity_list.append(new_entity) + changes.append(f"created {label} '{entity_id}'") + return None + + VALID_REMOVE_KEYS = {"dimensions", "measures", "aggregations", "joins"} + @mcp.tool() def edit_model( model_name: str, description: Optional[str] = None, data_source: Optional[str] = None, default_time_dimension: Optional[str] = None, - add_measures: Optional[List[Dict[str, Union[str, List[str]]]]] = None, - add_dimensions: Optional[List[Dict[str, str]]] = None, - remove: Optional[List[str]] = None, + sql_table: Optional[str] = None, + sql: Optional[str] = None, + hidden: Optional[bool] = None, + dimensions: Optional[List[Dict[str, Any]]] = None, + measures: Optional[List[Dict[str, Any]]] = None, + aggregations: Optional[List[Dict[str, Any]]] = None, + joins: Optional[List[Dict[str, Any]]] = None, + add_filters: Optional[List[str]] = None, + remove_filters: Optional[List[str]] = None, + remove: Optional[Dict[str, List[str]]] = None, ) -> str: - """Edit an existing model — update metadata, add/remove measures and dimensions in a single call. + """Edit an existing model in a single call — update metadata, upsert dimensions/measures/aggregations/joins, + manage filters, and remove entities. Args: model_name: Name of the model to edit. - description: New description for the model. + description: New model description. data_source: New data source name. - default_time_dimension: Default time dimension for transforms. - add_measures: Measures to add. Each: {"name": "total", "sql": "amount", "type": "sum", "description": "..."}. - Types: count, count_distinct, sum, avg, min, max. - Optional: "allowed_aggregations": ["sum", "avg"] to restrict usable aggregations. - add_dimensions: Dimensions to add. Each: {"name": "region", "sql": "region", "type": "string", "description": "..."}. + default_time_dimension: Default time dimension for time-dependent transforms. + sql_table: Database table name. + sql: Custom SQL expression for the model source. + hidden: Whether this model is hidden from discovery. + dimensions: Dimensions to create or update (upsert by name). Each dict: {"name": "col", "type": "string", "sql": "col", "description": "...", "primary_key": false, "hidden": false}. + If a dimension with this name exists, only the provided fields are updated; omitted fields keep current values. Types: string, number, time, date, boolean. - remove: Names of measures or dimensions to remove. + measures: Measures to create or update (upsert by name). Each dict: {"name": "total", "sql": "amount", "description": "...", "hidden": false, "allowed_aggregations": ["sum", "avg"]}. + If a measure with this name exists, only the provided fields are updated. + aggregations: Aggregations to create or update (upsert by name). Each dict: {"name": "weighted_avg", "formula": "SUM({value} * {weight}) / NULLIF(SUM({weight}), 0)", "params": [{"name": "weight", "sql": "quantity"}], "description": "..."}. + If an aggregation with this name exists, only the provided fields are updated. + joins: Joins to create or update (upsert by target_model). Each dict: {"target_model": "customers", "join_pairs": [["customer_id", "id"]]}. + If a join to this target_model exists, its join_pairs are updated. + add_filters: SQL filter strings to add (e.g. ["deleted_at IS NULL"]). Duplicates are ignored. + remove_filters: SQL filter strings to remove (exact match). + remove: Named entities to delete, keyed by type: {"dimensions": ["name1"], "measures": ["name2"], "aggregations": ["name3"], "joins": ["target_model_name"]}. + Removals are processed before upserts, so you can remove and re-add in one call. + + Example — update a dimension and add a measure: + edit_model(model_name="orders", dimensions=[{"name": "status", "type": "string"}], measures=[{"name": "profit", "sql": "revenue - cost"}]) + Example — remove a measure: + edit_model(model_name="orders", remove={"measures": ["old_metric"]}) """ model = storage.get_model(model_name) if model is None: return f"Model '{model_name}' not found." - changes = [] + changes: List[str] = [] - # Update metadata + # --- Phase 1: Scalar metadata --- if description is not None: model.description = description changes.append("updated description") @@ -397,56 +466,100 @@ def edit_model( if default_time_dimension is not None: model.default_time_dimension = default_time_dimension changes.append(f"set default_time_dimension to '{default_time_dimension}'") - - # Remove measures/dimensions + if sql_table is not None: + model.sql_table = sql_table + changes.append(f"set sql_table to '{sql_table}'") + if sql is not None: + model.sql = sql + changes.append(f"set sql to '{sql}'") + if hidden is not None: + model.hidden = hidden + changes.append(f"set hidden to {hidden}") + + # --- Phase 2: Removals --- if remove: - for name in remove: - match = [m for m in model.measures if m.name == name] - if match: - model.measures.remove(match[0]) - changes.append(f"removed measure '{name}'") - continue - match = [d for d in model.dimensions if d.name == name] - if match: - model.dimensions.remove(match[0]) - changes.append(f"removed dimension '{name}'") - continue - return f"'{name}' not found as a measure or dimension on model '{model_name}'." - - # Add measures - existing_measure_names = {m.name for m in model.measures} - for spec in (add_measures or []): - name = spec.get("name", "") - if name in existing_measure_names: - return f"Measure '{name}' already exists on model '{model_name}'." - model.measures.append(Measure( - name=name, - sql=spec.get("sql"), - description=spec.get("description"), - allowed_aggregations=spec.get("allowed_aggregations"), - )) - existing_measure_names.add(name) - changes.append(f"added measure '{name}'") - - # Add dimensions - existing_dim_names = {d.name for d in model.dimensions} - for spec in (add_dimensions or []): - name = spec.get("name", "") - if name in existing_dim_names: - return f"Dimension '{name}' already exists on model '{model_name}'." - dim_type = spec.get("type", "") - if dim_type not in VALID_DIMENSION_TYPES: - return f"Invalid dimension type '{dim_type}'. Must be one of: {', '.join(sorted(VALID_DIMENSION_TYPES))}" - model.dimensions.append(Dimension( - name=name, sql=spec.get("sql"), type=dim_type, description=spec.get("description"), - )) - existing_dim_names.add(name) - changes.append(f"added dimension '{name}'") + for key in remove: + if key not in VALID_REMOVE_KEYS: + return ( + f"Invalid remove key '{key}'. " + f"Must be one of: {', '.join(sorted(VALID_REMOVE_KEYS))}." + ) + + for name in remove.get("dimensions", []): + match = next((d for d in model.dimensions if d.name == name), None) + if match is None: + return f"Dimension '{name}' not found on model '{model_name}'." + model.dimensions.remove(match) + changes.append(f"removed dimension '{name}'") + + for name in remove.get("measures", []): + match = next((m for m in model.measures if m.name == name), None) + if match is None: + return f"Measure '{name}' not found on model '{model_name}'." + model.measures.remove(match) + changes.append(f"removed measure '{name}'") + + for name in remove.get("aggregations", []): + match = next((a for a in model.aggregations if a.name == name), None) + if match is None: + return f"Aggregation '{name}' not found on model '{model_name}'." + model.aggregations.remove(match) + changes.append(f"removed aggregation '{name}'") + + for target in remove.get("joins", []): + match = next((j for j in model.joins if j.target_model == target), None) + if match is None: + return f"Join to '{target}' not found on model '{model_name}'." + model.joins.remove(match) + changes.append(f"removed join to '{target}'") + + # --- Phase 3: Entity upserts --- + for spec in dimensions or []: + err = _upsert_entity(model.dimensions, spec, Dimension, "name", changes, "dimension") + if err: + return err + + for spec in measures or []: + err = _upsert_entity(model.measures, spec, Measure, "name", changes, "measure") + if err: + return err + + for spec in aggregations or []: + err = _upsert_entity(model.aggregations, spec, Aggregation, "name", changes, "aggregation") + if err: + return err + + for spec in joins or []: + err = _upsert_entity(model.joins, spec, ModelJoin, "target_model", changes, "join") + if err: + return err + + # --- Phase 4: Filters --- + if add_filters: + existing_filters = set(model.filters) + for f in add_filters: + if f not in existing_filters: + model.filters.append(f) + existing_filters.add(f) + changes.append(f"added filter '{f}'") + + if remove_filters: + for f in remove_filters: + if f not in model.filters: + return f"Filter not found on model '{model_name}': {f}" + model.filters.remove(f) + changes.append(f"removed filter '{f}'") if not changes: return f"No changes specified for model '{model_name}'." - storage.save_model(model) + # --- Phase 5: Validate and save --- + try: + validated = SlayerModel.model_validate(model.model_dump(mode="json")) + except Exception as exc: + return f"Validation error: {exc}" + + storage.save_model(validated) return json.dumps({ "success": True, "model_name": model_name, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 30be119..1d2d927 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -9,7 +9,14 @@ import pytest from slayer.core.enums import DataType -from slayer.core.models import DatasourceConfig, Dimension, Measure, SlayerModel +from slayer.core.models import ( + Aggregation, + DatasourceConfig, + Dimension, + Measure, + ModelJoin, + SlayerModel, +) from slayer.mcp.server import ( _format_table, _friendly_db_error, @@ -165,59 +172,259 @@ def test_create_from_query_routes_to_engine(self, mcp_server, storage: YAMLStora class TestEditModel: - def test_add_measure(self, mcp_server, storage: YAMLStorage) -> None: + """Tests for the edit_model MCP tool with upsert semantics.""" + + # --- Measure upserts --- + + def test_upsert_new_measure(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( name="orders", sql_table="t", data_source="test", measures=[Measure(name="revenue", sql="amount")], )) result = _call(mcp_server, "edit_model", { "model_name": "orders", - "add_measures": [{"name": "total", "sql": "amount"}], + "measures": [{"name": "total", "sql": "amount"}], }) parsed = json.loads(result) assert parsed["success"] is True + assert any("created measure 'total'" in c for c in parsed["changes"]) model = storage.get_model("orders") assert len(model.measures) == 2 - def test_add_measure_with_allowed_aggregations(self, mcp_server, storage: YAMLStorage) -> None: + def test_upsert_measure_with_allowed_aggregations(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( name="orders", sql_table="t", data_source="test", measures=[Measure(name="revenue", sql="amount")], )) result = _call(mcp_server, "edit_model", { "model_name": "orders", - "add_measures": [{"name": "total", "sql": "amount", "allowed_aggregations": ["sum", "avg"]}], + "measures": [{"name": "total", "sql": "amount", "allowed_aggregations": ["sum", "avg"]}], }) parsed = json.loads(result) assert parsed["success"] is True model = storage.get_model("orders") - total = [m for m in model.measures if m.name == "total"][0] + total = next(m for m in model.measures if m.name == "total") assert total.allowed_aggregations == ["sum", "avg"] - def test_add_dimension(self, mcp_server, storage: YAMLStorage) -> None: + def test_upsert_existing_measure(self, mcp_server, storage: YAMLStorage) -> None: + """Upserting an existing measure updates it instead of erroring.""" + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + measures=[Measure(name="revenue", sql="amount", description="old")], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "measures": [{"name": "revenue", "sql": "price"}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert any("updated measure 'revenue'" in c for c in parsed["changes"]) + model = storage.get_model("orders") + assert len(model.measures) == 1 + assert model.measures[0].sql == "price" + + def test_upsert_existing_measure_partial_update(self, mcp_server, storage: YAMLStorage) -> None: + """Partial upsert: only specified fields change, others are preserved.""" + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + measures=[Measure(name="revenue", sql="amount", description="Total revenue")], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "measures": [{"name": "revenue", "description": "Updated description"}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + m = storage.get_model("orders").measures[0] + assert m.description == "Updated description" + assert m.sql == "amount" # unchanged + + # --- Dimension upserts --- + + def test_upsert_new_dimension(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "dimensions": [{"name": "region", "sql": "region", "type": "string"}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert any("created dimension 'region'" in c for c in parsed["changes"]) + assert any(d.name == "region" for d in storage.get_model("orders").dimensions) + + def test_upsert_existing_dimension_partial_update(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + dimensions=[Dimension(name="status", sql="status", type=DataType.STRING)], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "dimensions": [{"name": "status", "description": "Order status"}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + d = storage.get_model("orders").dimensions[0] + assert d.description == "Order status" + assert d.sql == "status" # unchanged + assert d.type == DataType.STRING # unchanged + + def test_upsert_multiple_mixed_create_update(self, mcp_server, storage: YAMLStorage) -> None: + """One new + one existing entity in the same call.""" + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + measures=[Measure(name="revenue", sql="amount")], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "measures": [ + {"name": "revenue", "description": "Updated"}, + {"name": "profit", "sql": "revenue - cost"}, + ], + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert any("updated measure 'revenue'" in c for c in parsed["changes"]) + assert any("created measure 'profit'" in c for c in parsed["changes"]) + model = storage.get_model("orders") + assert len(model.measures) == 2 + + def test_invalid_dimension_type_on_upsert(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) result = _call(mcp_server, "edit_model", { "model_name": "orders", - "add_dimensions": [{"name": "region", "sql": "region", "type": "string"}], + "dimensions": [{"name": "bad", "type": "invalid_type"}], + }) + assert "Invalid" in result + + # --- Aggregation upserts --- + + def test_upsert_new_aggregation(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "aggregations": [{"name": "my_agg", "formula": "SUM({value})"}], }) parsed = json.loads(result) assert parsed["success"] is True model = storage.get_model("orders") - assert any(d.name == "region" for d in model.dimensions) + assert len(model.aggregations) == 1 + assert model.aggregations[0].name == "my_agg" - def test_remove(self, mcp_server, storage: YAMLStorage) -> None: + def test_upsert_existing_aggregation(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( name="orders", sql_table="t", data_source="test", - measures=[Measure(name="revenue", sql="amount"), Measure(name="total", sql="x")], + aggregations=[Aggregation(name="my_agg", formula="SUM({value})")], )) result = _call(mcp_server, "edit_model", { "model_name": "orders", - "remove": ["total"], + "aggregations": [{"name": "my_agg", "formula": "AVG({value})"}], }) parsed = json.loads(result) assert parsed["success"] is True model = storage.get_model("orders") - assert len(model.measures) == 1 + assert model.aggregations[0].formula == "AVG({value})" + + def test_remove_aggregation(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + aggregations=[Aggregation(name="my_agg", formula="SUM({value})")], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove": {"aggregations": ["my_agg"]}, + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert len(storage.get_model("orders").aggregations) == 0 + + # --- Join upserts --- + + def test_upsert_new_join(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "joins": [{"target_model": "customers", "join_pairs": [["customer_id", "id"]]}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + model = storage.get_model("orders") + assert len(model.joins) == 1 + assert model.joins[0].target_model == "customers" + + def test_upsert_existing_join(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + joins=[ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]])], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "joins": [{"target_model": "customers", "join_pairs": [["buyer_id", "id"]]}], + }) + parsed = json.loads(result) + assert parsed["success"] is True + model = storage.get_model("orders") + assert len(model.joins) == 1 + assert model.joins[0].join_pairs == [["buyer_id", "id"]] + + def test_remove_join(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + joins=[ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]])], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove": {"joins": ["customers"]}, + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert len(storage.get_model("orders").joins) == 0 + + # --- Filter management --- + + def test_add_filter(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "add_filters": ["deleted_at IS NULL"], + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert "deleted_at IS NULL" in storage.get_model("orders").filters + + def test_add_duplicate_filter_skipped(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + filters=["deleted_at IS NULL"], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "add_filters": ["deleted_at IS NULL"], + }) + # No changes because the filter already exists + assert "No changes" in result + + def test_remove_filter(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + filters=["deleted_at IS NULL"], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove_filters": ["deleted_at IS NULL"], + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert len(storage.get_model("orders").filters) == 0 + + def test_remove_filter_not_found(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove_filters": ["nonexistent"], + }) + assert "Filter not found" in result + + # --- Scalar metadata --- def test_update_description(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) @@ -229,6 +436,28 @@ def test_update_description(self, mcp_server, storage: YAMLStorage) -> None: assert parsed["success"] is True assert storage.get_model("orders").description == "Updated" + def test_set_sql_table(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "sql_table": "public.orders", + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert storage.get_model("orders").sql_table == "public.orders" + + def test_set_hidden(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "hidden": True, + }) + parsed = json.loads(result) + assert parsed["success"] is True + assert storage.get_model("orders").hidden is True + + # --- Multiple changes --- + def test_multiple_changes(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( name="orders", sql_table="t", data_source="test", @@ -237,8 +466,8 @@ def test_multiple_changes(self, mcp_server, storage: YAMLStorage) -> None: result = _call(mcp_server, "edit_model", { "model_name": "orders", "description": "Orders table", - "add_measures": [{"name": "total", "sql": "amount"}], - "add_dimensions": [{"name": "status", "sql": "status", "type": "string"}], + "measures": [{"name": "total", "sql": "amount"}], + "dimensions": [{"name": "status", "sql": "status", "type": "string"}], }) parsed = json.loads(result) assert parsed["success"] is True @@ -248,16 +477,55 @@ def test_multiple_changes(self, mcp_server, storage: YAMLStorage) -> None: assert len(model.measures) == 2 assert any(d.name == "status" for d in model.dimensions) - def test_duplicate_measure(self, mcp_server, storage: YAMLStorage) -> None: + # --- Typed remove --- + + def test_remove_measure_typed(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( name="orders", sql_table="t", data_source="test", - measures=[Measure(name="revenue", sql="amount")], + measures=[Measure(name="revenue", sql="amount"), Measure(name="total", sql="x")], + )) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove": {"measures": ["total"]}, + }) + parsed = json.loads(result) + assert parsed["success"] is True + model = storage.get_model("orders") + assert len(model.measures) == 1 + + def test_remove_dimension_not_found(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove": {"dimensions": ["nonexistent"]}, + }) + assert "not found" in result + + def test_remove_invalid_key(self, mcp_server, storage: YAMLStorage) -> None: + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "remove": {"invalid": ["x"]}, + }) + assert "Invalid remove key" in result + + def test_remove_then_recreate_same_call(self, mcp_server, storage: YAMLStorage) -> None: + """Remove a dimension then upsert one with the same name in the same call.""" + storage.save_model(SlayerModel( + name="orders", sql_table="t", data_source="test", + dimensions=[Dimension(name="status", sql="old_col", type=DataType.STRING)], )) result = _call(mcp_server, "edit_model", { "model_name": "orders", - "add_measures": [{"name": "revenue", "sql": "x"}], + "remove": {"dimensions": ["status"]}, + "dimensions": [{"name": "status", "sql": "new_col", "type": "string"}], }) - assert "already exists" in result + parsed = json.loads(result) + assert parsed["success"] is True + d = storage.get_model("orders").dimensions[0] + assert d.sql == "new_col" + + # --- Error cases --- def test_model_not_found(self, mcp_server) -> None: result = _call(mcp_server, "edit_model", { @@ -271,6 +539,15 @@ def test_no_changes(self, mcp_server, storage: YAMLStorage) -> None: result = _call(mcp_server, "edit_model", {"model_name": "orders"}) assert "No changes" in result + def test_cross_field_validation_error(self, mcp_server, storage: YAMLStorage) -> None: + """allowed_aggregations referencing a non-existent aggregation should fail.""" + storage.save_model(SlayerModel(name="orders", sql_table="t", data_source="test")) + result = _call(mcp_server, "edit_model", { + "model_name": "orders", + "measures": [{"name": "rev", "sql": "amount", "allowed_aggregations": ["nonexistent_agg"]}], + }) + assert "Validation error" in result or "not a built-in aggregation" in result + class TestDatasources: def test_list_empty(self, mcp_server) -> None: From 78fa66be8a34924894c6b8110bb1efb997299b9e Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 13 Apr 2026 10:08:23 +0200 Subject: [PATCH 5/8] Update slayer/mcp/server.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- slayer/mcp/server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index 4a75533..b9a8023 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -466,11 +466,16 @@ def edit_model( if default_time_dimension is not None: model.default_time_dimension = default_time_dimension changes.append(f"set default_time_dimension to '{default_time_dimension}'") + if sql_table is not None and sql is not None: + return "Specify only one of 'sql_table' or 'sql' when editing a model." + if sql_table is not None: model.sql_table = sql_table + model.sql = None changes.append(f"set sql_table to '{sql_table}'") if sql is not None: model.sql = sql + model.sql_table = None changes.append(f"set sql to '{sql}'") if hidden is not None: model.hidden = hidden From ce76ebd500be85e0fe58ad5f2b1280456d4282ff Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 13 Apr 2026 10:14:25 +0200 Subject: [PATCH 6/8] Fix datasource_summary return value --- slayer/mcp/server.py | 2 +- tests/test_mcp_server.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index 4a75533..93bd788 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -230,7 +230,7 @@ def datasource_summary() -> str: models.append(_model_to_summary(model)) if not datasources and not models: - return "No datasources or models configured. Use create_datasource to connect a database." + return json.dumps({"datasources": [], "models": [], "model_count": 0}) result = {} if datasources: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 1d2d927..36a6d6f 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -45,7 +45,10 @@ def _call(mcp_server, name: str, arguments: dict[str, Any] = {}) -> str: class TestDatasourceSummary: def test_empty(self, mcp_server) -> None: result = _call(mcp_server, "datasource_summary") - assert "No datasources or models" in result + data = json.loads(result) + assert data["datasources"] == [] + assert data["models"] == [] + assert data["model_count"] == 0 def test_with_models(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel( From bce3f82531e6c8cb92a2cbdcf0d459e13d69e638 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 13 Apr 2026 10:51:10 +0200 Subject: [PATCH 7/8] CodeRabbit fixes --- slayer/mcp/server.py | 60 ++++++++++++++++++++++++++-------------- tests/test_mcp_server.py | 27 ++++++++++++++++++ 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/slayer/mcp/server.py b/slayer/mcp/server.py index f33679a..388680b 100644 --- a/slayer/mcp/server.py +++ b/slayer/mcp/server.py @@ -218,8 +218,9 @@ def datasource_summary() -> str: if ds.description: entry["description"] = ds.description datasources.append(entry) - except (ValueError, Exception) as exc: - datasources.append({"name": name, "error": str(exc)}) + except Exception as exc: + logger.warning("Failed to load datasource '%s': %s", name, exc) + datasources.append({"name": name, "error": "invalid datasource config"}) # Models model_names = storage.list_models() @@ -232,11 +233,11 @@ def datasource_summary() -> str: if not datasources and not models: return json.dumps({"datasources": [], "models": [], "model_count": 0}) - result = {} - if datasources: - result["datasources"] = datasources - result["models"] = models - result["model_count"] = len(models) + result = { + "datasources": datasources, + "models": models, + "model_count": len(models), + } return json.dumps(result, indent=2, default=str) @@ -330,9 +331,13 @@ def create_model( sql_table, sql, dimensions, and measures. """ if query is not None: - table_params = _build_dict( - sql_table=sql_table, sql=sql, dimensions=dimensions, measures=measures, - ) + table_params = { + k: v for k, v in { + "sql_table": sql_table, "sql": sql, "data_source": data_source, + "dimensions": dimensions, "measures": measures, + }.items() + if v + } if table_params: return ( f"Error: 'query' cannot be combined with {', '.join(table_params.keys())}. " @@ -520,22 +525,34 @@ def edit_model( # --- Phase 3: Entity upserts --- for spec in dimensions or []: - err = _upsert_entity(model.dimensions, spec, Dimension, "name", changes, "dimension") + err = _upsert_entity( + entity_list=model.dimensions, spec=spec, entity_cls=Dimension, + id_field="name", changes=changes, label="dimension", + ) if err: return err for spec in measures or []: - err = _upsert_entity(model.measures, spec, Measure, "name", changes, "measure") + err = _upsert_entity( + entity_list=model.measures, spec=spec, entity_cls=Measure, + id_field="name", changes=changes, label="measure", + ) if err: return err for spec in aggregations or []: - err = _upsert_entity(model.aggregations, spec, Aggregation, "name", changes, "aggregation") + err = _upsert_entity( + entity_list=model.aggregations, spec=spec, entity_cls=Aggregation, + id_field="name", changes=changes, label="aggregation", + ) if err: return err for spec in joins or []: - err = _upsert_entity(model.joins, spec, ModelJoin, "target_model", changes, "join") + err = _upsert_entity( + entity_list=model.joins, spec=spec, entity_cls=ModelJoin, + id_field="target_model", changes=changes, label="join", + ) if err: return err @@ -664,8 +681,9 @@ def list_datasources() -> str: ds = storage.get_datasource(name) ds_type = ds.type if ds else "unknown" lines.append(f"- {name} ({ds_type})") - except (ValueError, Exception) as exc: - lines.append(f"- {name} (ERROR: {exc})") + except Exception as exc: + logger.warning("Failed to load datasource '%s': %s", name, exc) + lines.append(f"- {name} (ERROR: invalid datasource config)") return "\n".join(lines) @mcp.tool() @@ -677,8 +695,9 @@ def describe_datasource(name: str) -> str: """ try: ds = storage.get_datasource(name) - except (ValueError, Exception) as exc: - return f"Datasource '{name}' has an invalid config: {exc}" + except Exception as exc: + logger.warning("Failed to load datasource '%s': %s", name, exc) + return f"Datasource '{name}' has an invalid config." if ds is None: return f"Datasource '{name}' not found." @@ -718,8 +737,9 @@ def list_tables(datasource_name: str, schema_name: str = "") -> str: """ try: ds = storage.get_datasource(datasource_name) - except (ValueError, Exception) as exc: - return f"Datasource '{datasource_name}' has an invalid config: {exc}" + except Exception as exc: + logger.warning("Failed to load datasource '%s': %s", datasource_name, exc) + return f"Datasource '{datasource_name}' has an invalid config." if ds is None: return f"Datasource '{datasource_name}' not found." try: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 36a6d6f..96d2ac1 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -64,6 +64,9 @@ def test_with_models(self, mcp_server, storage: YAMLStorage) -> None: assert parsed["models"][0]["name"] == "orders" assert len(parsed["models"][0]["dimensions"]) == 1 assert len(parsed["models"][0]["measures"]) == 1 + # Envelope is always stable — datasources key present even when empty + assert "datasources" in parsed + assert parsed["datasources"] == [] def test_hidden_models_excluded(self, mcp_server, storage: YAMLStorage) -> None: storage.save_model(SlayerModel(name="visible", sql_table="t", data_source="test")) @@ -159,6 +162,30 @@ def test_create_from_query_rejects_mixed_params(self, mcp_server) -> None: assert "query" in result assert "sql_table" in result + def test_create_from_query_rejects_data_source(self, mcp_server) -> None: + result = _call(mcp_server, "create_model", { + "name": "bad", + "query": {"source_model": "orders", "fields": ["*:count"]}, + "data_source": "mydb", + }) + assert "Error" in result + assert "data_source" in result + + def test_create_from_query_ignores_empty_placeholders(self, mcp_server, storage: YAMLStorage) -> None: + """Empty lists/strings should not trigger the mixed-parameter error.""" + storage.save_model(SlayerModel( + name="orders", sql_table="orders", data_source="test_ds", + measures=[Measure(name="amount", sql="amount")], + )) + result = _call(mcp_server, "create_model", { + "name": "summary", + "query": {"source_model": "orders", "fields": ["amount:sum"]}, + "dimensions": [], + "measures": [], + }) + # Should route to query path (fails on missing datasource), not mixed-param error + assert "Error:" not in result or "Datasource" in result + def test_create_from_query_routes_to_engine(self, mcp_server, storage: YAMLStorage) -> None: # Without a real datasource/data, the engine will return a friendly error — # but the error message proves we routed to the query path. From 95698787dd32e19abf5615a72f10c3dcaaf5667b Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Mon, 13 Apr 2026 10:52:58 +0200 Subject: [PATCH 8/8] Change CodeRabbit config to review draft PRs --- .coderabbit.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..93e0a95 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +reviews: + auto_review: + enabled: true + drafts: true # Enable reviews on draft PRs + base_branches: ['.*'] # Enable reviews on all branches except the default