sqlmesh-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. sqlmesh_mcp-0.1.0/.github/workflows/ci.yml +21 -0
  2. sqlmesh_mcp-0.1.0/.github/workflows/publish.yml +42 -0
  3. sqlmesh_mcp-0.1.0/.gitignore +16 -0
  4. sqlmesh_mcp-0.1.0/CHANGELOG.md +19 -0
  5. sqlmesh_mcp-0.1.0/LICENSE +21 -0
  6. sqlmesh_mcp-0.1.0/PKG-INFO +125 -0
  7. sqlmesh_mcp-0.1.0/README.md +101 -0
  8. sqlmesh_mcp-0.1.0/TEST_CASES.md +58 -0
  9. sqlmesh_mcp-0.1.0/examples/demo_project/audits/.gitkeep +0 -0
  10. sqlmesh_mcp-0.1.0/examples/demo_project/audits/assert_positive_order_ids.sql +9 -0
  11. sqlmesh_mcp-0.1.0/examples/demo_project/config.yaml +42 -0
  12. sqlmesh_mcp-0.1.0/examples/demo_project/macros/.gitkeep +0 -0
  13. sqlmesh_mcp-0.1.0/examples/demo_project/macros/__init__.py +0 -0
  14. sqlmesh_mcp-0.1.0/examples/demo_project/models/.gitkeep +0 -0
  15. sqlmesh_mcp-0.1.0/examples/demo_project/models/full_model.sql +15 -0
  16. sqlmesh_mcp-0.1.0/examples/demo_project/models/incremental_model.sql +19 -0
  17. sqlmesh_mcp-0.1.0/examples/demo_project/models/seed_model.sql +13 -0
  18. sqlmesh_mcp-0.1.0/examples/demo_project/seeds/.gitkeep +0 -0
  19. sqlmesh_mcp-0.1.0/examples/demo_project/seeds/seed_data.csv +8 -0
  20. sqlmesh_mcp-0.1.0/examples/demo_project/tests/.gitkeep +0 -0
  21. sqlmesh_mcp-0.1.0/examples/demo_project/tests/test_full_model.yaml +19 -0
  22. sqlmesh_mcp-0.1.0/pyproject.toml +47 -0
  23. sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/__init__.py +1 -0
  24. sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/context.py +23 -0
  25. sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/py.typed +0 -0
  26. sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/server.py +232 -0
  27. sqlmesh_mcp-0.1.0/tests/test_protocol.py +126 -0
  28. sqlmesh_mcp-0.1.0/tests/test_server.py +192 -0
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: ruff check src tests
21
+ - run: pytest
@@ -0,0 +1,42 @@
1
+ name: Publish to PyPI
2
+
3
+ # Triggered by cutting a GitHub Release (tag it vX.Y.Z to match pyproject.toml's
4
+ # `version`). Uses PyPI Trusted Publishing (OIDC) -- no API token stored in
5
+ # this repo. The corresponding "pending publisher" must be configured once on
6
+ # pypi.org (Account Settings -> Publishing) for the sqlmesh-mcp project,
7
+ # pointing at this repo, this workflow filename, and the `pypi` environment
8
+ # below, before the first release is cut.
9
+
10
+ on:
11
+ release:
12
+ types: [published]
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+ - run: pip install build
23
+ - run: python -m build
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment:
33
+ name: pypi
34
+ url: https://pypi.org/project/sqlmesh-mcp/
35
+ permissions:
36
+ id-token: write # required for trusted publishing
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .venv/
9
+ venv/
10
+ .DS_Store
11
+
12
+ # SQLMesh runtime artifacts from running the demo project / tests
13
+ *.db
14
+ *.db.wal
15
+ examples/demo_project/.cache/
16
+ examples/demo_project/logs/
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.1.0] - Unreleased
7
+
8
+ Initial release.
9
+
10
+ ### Added
11
+
12
+ - `list_models`, `get_model` -- model metadata and rendered queries.
13
+ - `plan`, `apply_plan` -- preview and (with `confirm=true`) apply a plan.
14
+ - `lineage` -- column-level lineage for a model's column.
15
+ - `run_audit`, `run_test` -- run a model's audits / unit tests.
16
+ - `diff_environment`, `list_environments` -- inspect environment state.
17
+ - `run` -- execute due scheduled runs for an environment (requires `confirm=true`).
18
+ - Protocol-level test suite (`tests/test_protocol.py`) spawning the server
19
+ as a real MCP client would, alongside direct function-call tests.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zain Ul Abdin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.5
2
+ Name: sqlmesh-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing a SQLMesh project (models, plans, column-level lineage, audits) to LLM agents
5
+ Project-URL: Homepage, https://github.com/Zain-ul-Abdin45/sqlmesh-mcp
6
+ Project-URL: Issues, https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/issues
7
+ Author: Zain Ul Abdin
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,data-engineering,llm,mcp,sqlmesh
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Database
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: mcp>=2.0.0
18
+ Requires-Dist: sqlmesh>=0.236.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.6; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # sqlmesh-mcp
26
+
27
+ [![CI](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml)
28
+ [![PyPI](https://img.shields.io/pypi/v/sqlmesh-mcp.svg)](https://pypi.org/project/sqlmesh-mcp/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/sqlmesh-mcp.svg)](https://pypi.org/project/sqlmesh-mcp/)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
31
+
32
+ MCP server exposing a [SQLMesh](https://github.com/SQLMesh/sqlmesh) project to LLM agents: model metadata, plan previews, column-level lineage, audits/tests, and environment diffs.
33
+
34
+ Not officially affiliated with SQLMesh or Tobiko Data.
35
+
36
+ ## Why
37
+
38
+ SQLMesh's standout feature is column-level lineage, which is exactly the kind of question an agent is good at answering interactively ("where does `revenue` in `finance.daily_summary` come from?") that a CLI isn't. As of writing, the only prior MCP server for SQLMesh ([`sherman94062/sqlmesh-mcp`](https://github.com/sherman94062/sqlmesh-mcp)) is a small unmaintained side project — this one aims to be documented, tested, and kept current with SQLMesh's API.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install sqlmesh-mcp
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ Point it at a SQLMesh project directory:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "sqlmesh": {
54
+ "command": "sqlmesh-mcp",
55
+ "env": { "SQLMESH_PROJECT_PATH": "/path/to/your/sqlmesh/project" }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ## Example
62
+
63
+ Calling `list_models` against [`examples/demo_project`](examples/demo_project) (a stock `sqlmesh init duckdb` project) returns:
64
+
65
+ ```json
66
+ [
67
+ {
68
+ "name": "sqlmesh_example.full_model",
69
+ "kind": "FULL",
70
+ "description": null,
71
+ "owner": null,
72
+ "tags": [],
73
+ "columns": { "item_id": "INT", "num_orders": "BIGINT" }
74
+ },
75
+ {
76
+ "name": "sqlmesh_example.incremental_model",
77
+ "kind": "INCREMENTAL_BY_TIME_RANGE",
78
+ "description": null,
79
+ "owner": null,
80
+ "tags": [],
81
+ "columns": { "id": "INT", "item_id": "INT", "event_date": "DATE" }
82
+ },
83
+ {
84
+ "name": "sqlmesh_example.seed_model",
85
+ "kind": "SEED",
86
+ "description": null,
87
+ "owner": null,
88
+ "tags": [],
89
+ "columns": { "id": "INT", "item_id": "INT", "event_date": "DATE" }
90
+ }
91
+ ]
92
+ ```
93
+
94
+ From there, `lineage("sqlmesh_example.full_model", "num_orders")` traces that column back to `incremental_model.id` — the kind of question this server exists for.
95
+
96
+ ## Tools
97
+
98
+ | Tool | Read-only? | Description |
99
+ |---|---|---|
100
+ | `list_models` | Yes | List all models in the project with kind, columns, description |
101
+ | `get_model` | Yes | Full detail for one model |
102
+ | `plan` | Yes | Preview what a plan against an environment would change |
103
+ | `apply_plan` | **No** | Apply a previously-previewed plan. Requires `confirm=true`. |
104
+ | `lineage` | Yes | Column-level lineage for a model's column |
105
+ | `run_audit` | Yes | Run a model's audits |
106
+ | `run_test` | Yes | Run a model's unit tests |
107
+ | `diff_environment` | Yes | Diff two environments |
108
+ | `list_environments` | Yes | List every environment that exists in the project's state |
109
+ | `run` | **No** | Execute scheduled/due model runs for an environment (what a cron trigger would do). Requires `confirm=true`. |
110
+
111
+ `apply_plan` and `run` are the two tools that change real data in whatever warehouse the project points at. Every other tool is read-only. Both are marked `destructiveHint`/non-`readOnlyHint` in their MCP tool annotations so clients can warn a user before calling them.
112
+
113
+ One server process is scoped to a single SQLMesh project, set once via `SQLMESH_PROJECT_PATH` (the context is cached for the life of the process). Point a client at multiple projects by running multiple server instances, one per `SQLMESH_PROJECT_PATH`.
114
+
115
+ ### Not yet covered
116
+
117
+ SQLMesh's `table_diff` and `format` commands aren't exposed as tools yet — planned, not forgotten. Contributions welcome.
118
+
119
+ ## Testing
120
+
121
+ See [`TEST_CASES.md`](TEST_CASES.md) for a plain-English index of every test case and what it covers, including a real bug the protocol-level tests caught that direct function-call tests couldn't (tool errors getting silently replaced with a generic message unless raised as the SDK's own `ToolError`).
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,101 @@
1
+ # sqlmesh-mcp
2
+
3
+ [![CI](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/sqlmesh-mcp.svg)](https://pypi.org/project/sqlmesh-mcp/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/sqlmesh-mcp.svg)](https://pypi.org/project/sqlmesh-mcp/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ MCP server exposing a [SQLMesh](https://github.com/SQLMesh/sqlmesh) project to LLM agents: model metadata, plan previews, column-level lineage, audits/tests, and environment diffs.
9
+
10
+ Not officially affiliated with SQLMesh or Tobiko Data.
11
+
12
+ ## Why
13
+
14
+ SQLMesh's standout feature is column-level lineage, which is exactly the kind of question an agent is good at answering interactively ("where does `revenue` in `finance.daily_summary` come from?") that a CLI isn't. As of writing, the only prior MCP server for SQLMesh ([`sherman94062/sqlmesh-mcp`](https://github.com/sherman94062/sqlmesh-mcp)) is a small unmaintained side project — this one aims to be documented, tested, and kept current with SQLMesh's API.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install sqlmesh-mcp
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Point it at a SQLMesh project directory:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "sqlmesh": {
30
+ "command": "sqlmesh-mcp",
31
+ "env": { "SQLMESH_PROJECT_PATH": "/path/to/your/sqlmesh/project" }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## Example
38
+
39
+ Calling `list_models` against [`examples/demo_project`](examples/demo_project) (a stock `sqlmesh init duckdb` project) returns:
40
+
41
+ ```json
42
+ [
43
+ {
44
+ "name": "sqlmesh_example.full_model",
45
+ "kind": "FULL",
46
+ "description": null,
47
+ "owner": null,
48
+ "tags": [],
49
+ "columns": { "item_id": "INT", "num_orders": "BIGINT" }
50
+ },
51
+ {
52
+ "name": "sqlmesh_example.incremental_model",
53
+ "kind": "INCREMENTAL_BY_TIME_RANGE",
54
+ "description": null,
55
+ "owner": null,
56
+ "tags": [],
57
+ "columns": { "id": "INT", "item_id": "INT", "event_date": "DATE" }
58
+ },
59
+ {
60
+ "name": "sqlmesh_example.seed_model",
61
+ "kind": "SEED",
62
+ "description": null,
63
+ "owner": null,
64
+ "tags": [],
65
+ "columns": { "id": "INT", "item_id": "INT", "event_date": "DATE" }
66
+ }
67
+ ]
68
+ ```
69
+
70
+ From there, `lineage("sqlmesh_example.full_model", "num_orders")` traces that column back to `incremental_model.id` — the kind of question this server exists for.
71
+
72
+ ## Tools
73
+
74
+ | Tool | Read-only? | Description |
75
+ |---|---|---|
76
+ | `list_models` | Yes | List all models in the project with kind, columns, description |
77
+ | `get_model` | Yes | Full detail for one model |
78
+ | `plan` | Yes | Preview what a plan against an environment would change |
79
+ | `apply_plan` | **No** | Apply a previously-previewed plan. Requires `confirm=true`. |
80
+ | `lineage` | Yes | Column-level lineage for a model's column |
81
+ | `run_audit` | Yes | Run a model's audits |
82
+ | `run_test` | Yes | Run a model's unit tests |
83
+ | `diff_environment` | Yes | Diff two environments |
84
+ | `list_environments` | Yes | List every environment that exists in the project's state |
85
+ | `run` | **No** | Execute scheduled/due model runs for an environment (what a cron trigger would do). Requires `confirm=true`. |
86
+
87
+ `apply_plan` and `run` are the two tools that change real data in whatever warehouse the project points at. Every other tool is read-only. Both are marked `destructiveHint`/non-`readOnlyHint` in their MCP tool annotations so clients can warn a user before calling them.
88
+
89
+ One server process is scoped to a single SQLMesh project, set once via `SQLMESH_PROJECT_PATH` (the context is cached for the life of the process). Point a client at multiple projects by running multiple server instances, one per `SQLMESH_PROJECT_PATH`.
90
+
91
+ ### Not yet covered
92
+
93
+ SQLMesh's `table_diff` and `format` commands aren't exposed as tools yet — planned, not forgotten. Contributions welcome.
94
+
95
+ ## Testing
96
+
97
+ See [`TEST_CASES.md`](TEST_CASES.md) for a plain-English index of every test case and what it covers, including a real bug the protocol-level tests caught that direct function-call tests couldn't (tool errors getting silently replaced with a generic message unless raised as the SDK's own `ToolError`).
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,58 @@
1
+ # Test cases
2
+
3
+ Plain-English index of every case the test suite covers, and why it exists.
4
+ Both files run against `examples/demo_project`, a real SQLMesh project
5
+ (generated with `sqlmesh init duckdb`, not hand-written fixtures) backed by
6
+ DuckDB.
7
+
8
+ Run everything: `pytest`. Run just one file: `pytest tests/test_server.py`.
9
+
10
+ ## `tests/test_server.py` — direct function calls
11
+
12
+ Calls the tool functions directly, bypassing the MCP protocol layer. Fast,
13
+ and proves our own logic is correct in isolation.
14
+
15
+ | Test | What it checks |
16
+ |---|---|
17
+ | `test_list_models_returns_the_demo_project_models` | All three demo models are returned, with correct kind and columns |
18
+ | `test_get_model_includes_rendered_query` | Full model detail includes the actual rendered SQL query |
19
+ | `test_get_model_raises_tool_error_with_the_real_message_for_unknown_model` | Looking up a nonexistent model raises `ToolError`, not a generic exception |
20
+ | `test_lineage_traces_column_to_upstream_model` | Column-level lineage correctly traces `full_model.num_orders` back to `incremental_model` |
21
+ | `test_run_test_passes_on_the_untouched_demo_project` | Unit tests run and pass against the unmodified demo project |
22
+ | `test_list_environments_is_empty_before_anything_is_applied` | No environments exist before any plan has been applied |
23
+ | `test_plan_previews_the_initial_environment_without_applying` | `plan()` shows all three models as new, normalizes their names to plain `schema.model` form, and does **not** apply anything |
24
+ | `test_apply_plan_refuses_without_confirm` | `apply_plan()` without `confirm=true` raises `ToolError`, nothing gets applied |
25
+ | `test_apply_plan_rejects_unknown_plan_id` | Applying a `plan_id` that was never previewed raises `ToolError` rather than silently doing nothing |
26
+ | `test_apply_plan_actually_applies_when_confirmed` | With `confirm=true`, the plan is actually applied and removed from the in-memory cache |
27
+ | `test_run_audit_passes_once_the_project_has_been_applied` | Audits pass against real, versioned data (previously **completely untested** — calling this against an unapplied project raises SQLMesh's own `ConfigError`, which the test now confirms is avoided by applying first) |
28
+ | `test_diff_environment_shows_no_diff_immediately_after_apply` | Diffing an environment right after applying it correctly shows no changes |
29
+ | `test_list_environments_shows_the_applied_environment` | Once a plan is applied, the environment shows up in `list_environments()` |
30
+ | `test_run_reports_nothing_to_do_immediately_after_apply` | Running right after `apply_plan` (which already backfilled everything) correctly reports `NOTHING_TO_DO`, not an error |
31
+ | `test_run_refuses_without_confirm` | Same `confirm=true` gate as `apply_plan`, verified independently for `run` |
32
+
33
+ ## `tests/test_protocol.py` — real MCP client over stdio
34
+
35
+ Spawns the server as a real subprocess and talks to it the way an actual MCP
36
+ client (Claude Desktop, etc.) would — a real JSON-RPC handshake, not a
37
+ Python function call. This is the file that caught the most important bug
38
+ found during development.
39
+
40
+ | Test | What it checks |
41
+ |---|---|
42
+ | `test_lists_all_ten_tools` | The server advertises exactly the ten tools it should, over the real protocol |
43
+ | `test_mutating_tools_are_flagged_destructive_and_not_read_only` | `apply_plan` and `run` both carry `destructive_hint=True`, `read_only_hint=False` in their MCP tool annotations — the signal a client uses to warn a user before calling them |
44
+ | `test_read_only_tools_are_flagged_read_only` | Every other tool is correctly flagged `read_only_hint=True` |
45
+ | `test_list_models_call_round_trips_real_data` | A real tool call over stdio returns real model data, not just a well-formed empty response |
46
+ | `test_apply_plan_without_confirm_is_a_tool_error_with_the_real_message` | **The regression this file exists to catch.** Without raising `ToolError` specifically, the MCP SDK replaces *any* exception with the generic string `"Error executing tool <name>"` and drops the real message entirely — an agent calling `apply_plan` without `confirm=true` would see no indication of what to fix. This test confirms the actual "confirm=true" guidance reaches the client. |
47
+ | `test_get_model_for_unknown_model_is_a_tool_error_with_the_real_message` | Same check for SQLMesh's own error message ("Cannot find model...") — confirms the `_translate_errors` decorator correctly forwards `SQLMeshError` text instead of it being swallowed |
48
+ | `test_session_survives_a_tool_error_and_keeps_working` | A tool error doesn't crash the server process or corrupt the session — proven by making a real, successful call immediately after two failures |
49
+
50
+ ## Why two files instead of one
51
+
52
+ Direct-call tests are fast and good at proving logic is right. They are
53
+ *not* sufficient on their own — this project's error-handling bug (tool
54
+ exceptions being silently replaced with a generic message unless they're a
55
+ specific SDK exception type) only exists at the protocol boundary, invisible
56
+ to any test that just calls the Python function and catches the exception
57
+ itself. Both layers are tested because each one catches a different class of
58
+ bug the other cannot see.
@@ -0,0 +1,9 @@
1
+ AUDIT (
2
+ name assert_positive_order_ids,
3
+ );
4
+
5
+ SELECT *
6
+ FROM @this_model
7
+ WHERE
8
+ item_id < 0
9
+
@@ -0,0 +1,42 @@
1
+ # --- Gateway Connection ---
2
+ gateways:
3
+ duckdb:
4
+ connection:
5
+ # For more information on configuring the connection to your execution engine, visit:
6
+ # https://sqlmesh.readthedocs.io/en/stable/reference/configuration/#connection
7
+ # https://sqlmesh.readthedocs.io/en/stable/integrations/engines/duckdb/#connection-options
8
+ type: duckdb
9
+ database: db.db
10
+ # concurrent_tasks: 1
11
+ # register_comments: True
12
+ # pre_ping: False
13
+ # pretty_sql: False
14
+ # schema_differ_overrides:
15
+ # catalog_type_overrides:
16
+ # catalogs:
17
+ # extensions:
18
+ # connector_config:
19
+ # secrets:
20
+ # filesystems:
21
+ # token:
22
+
23
+ default_gateway: duckdb
24
+
25
+ # --- Model Defaults ---
26
+ # https://sqlmesh.readthedocs.io/en/stable/reference/model_configuration/#model-defaults
27
+
28
+ model_defaults:
29
+ dialect: duckdb
30
+ start: 2026-09-16 # Start date for backfill history
31
+ cron: '@daily' # Run models daily at 12am UTC (can override per model)
32
+
33
+ # --- Linting Rules ---
34
+ # Enforce standards for your team
35
+ # https://sqlmesh.readthedocs.io/en/stable/guides/linter/
36
+
37
+ linter:
38
+ enabled: true
39
+ rules:
40
+ - ambiguousorinvalidcolumn
41
+ - invalidselectstarexpansion
42
+ - noambiguousprojections
@@ -0,0 +1,15 @@
1
+ MODEL (
2
+ name sqlmesh_example.full_model,
3
+ kind FULL,
4
+ cron '@daily',
5
+ grain item_id,
6
+ audits (assert_positive_order_ids),
7
+ );
8
+
9
+ SELECT
10
+ item_id,
11
+ COUNT(DISTINCT id) AS num_orders,
12
+ FROM
13
+ sqlmesh_example.incremental_model
14
+ GROUP BY item_id
15
+
@@ -0,0 +1,19 @@
1
+ MODEL (
2
+ name sqlmesh_example.incremental_model,
3
+ kind INCREMENTAL_BY_TIME_RANGE (
4
+ time_column event_date
5
+ ),
6
+ start '2020-01-01',
7
+ cron '@daily',
8
+ grain (id, event_date)
9
+ );
10
+
11
+ SELECT
12
+ id,
13
+ item_id,
14
+ event_date,
15
+ FROM
16
+ sqlmesh_example.seed_model
17
+ WHERE
18
+ event_date BETWEEN @start_date AND @end_date
19
+
@@ -0,0 +1,13 @@
1
+ MODEL (
2
+ name sqlmesh_example.seed_model,
3
+ kind SEED (
4
+ path '../seeds/seed_data.csv'
5
+ ),
6
+ columns (
7
+ id INTEGER,
8
+ item_id INTEGER,
9
+ event_date DATE
10
+ ),
11
+ grain (id, event_date)
12
+ );
13
+
File without changes
@@ -0,0 +1,8 @@
1
+ id,item_id,event_date
2
+ 1,2,2020-01-01
3
+ 2,1,2020-01-01
4
+ 3,3,2020-01-03
5
+ 4,1,2020-01-04
6
+ 5,1,2020-01-05
7
+ 6,1,2020-01-06
8
+ 7,1,2020-01-07
File without changes
@@ -0,0 +1,19 @@
1
+ test_example_full_model:
2
+ model: sqlmesh_example.full_model
3
+ inputs:
4
+ sqlmesh_example.incremental_model:
5
+ rows:
6
+ - id: 1
7
+ item_id: 1
8
+ - id: 2
9
+ item_id: 1
10
+ - id: 3
11
+ item_id: 2
12
+ outputs:
13
+ query:
14
+ rows:
15
+ - item_id: 1
16
+ num_orders: 2
17
+ - item_id: 2
18
+ num_orders: 1
19
+
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sqlmesh-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing a SQLMesh project (models, plans, column-level lineage, audits) to LLM agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Zain Ul Abdin" }]
13
+ keywords = ["mcp", "sqlmesh", "data-engineering", "llm", "agent"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Database",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=2.0.0",
23
+ "sqlmesh>=0.236.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.6"]
28
+
29
+ [project.scripts]
30
+ sqlmesh-mcp = "sqlmesh_mcp.server:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/Zain-ul-Abdin45/sqlmesh-mcp"
34
+ Issues = "https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/sqlmesh_mcp"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ asyncio_mode = "auto"
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,23 @@
1
+ """Lazily-created, cached SQLMesh Context for the configured project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from functools import lru_cache
7
+
8
+ from sqlmesh.core.context import Context
9
+
10
+
11
+ class ProjectNotConfiguredError(RuntimeError):
12
+ pass
13
+
14
+
15
+ @lru_cache(maxsize=1)
16
+ def get_context() -> Context:
17
+ path = os.environ.get("SQLMESH_PROJECT_PATH")
18
+ if not path:
19
+ raise ProjectNotConfiguredError(
20
+ "SQLMESH_PROJECT_PATH is not set. Point it at a directory containing "
21
+ "a SQLMesh config.py/config.yaml."
22
+ )
23
+ return Context(paths=path)
File without changes
@@ -0,0 +1,232 @@
1
+ """sqlmesh-mcp: exposes a SQLMesh project to LLM agents via MCP.
2
+
3
+ Every tool here is read-only except apply_plan, which is the one tool that
4
+ changes real data in whatever warehouse the project points at.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from typing import Any
11
+
12
+ import sqlglot
13
+ from mcp.server.mcpserver import MCPServer
14
+ from mcp.server.mcpserver.exceptions import ToolError
15
+ from mcp_types import ToolAnnotations
16
+ from sqlmesh.core.lineage import column_dependencies
17
+ from sqlmesh.utils.errors import SQLMeshError
18
+
19
+ from .context import get_context
20
+
21
+ server = MCPServer("sqlmesh-mcp")
22
+
23
+ # Plans previewed via `plan` are cached here so `apply_plan` can apply exactly
24
+ # what was shown, keyed by Plan.plan_id. Plan objects aren't JSON-serializable
25
+ # and re-running plan() at apply time could compute something different if the
26
+ # project changed in between preview and apply.
27
+ _PLAN_CACHE: dict[str, Any] = {}
28
+
29
+
30
+ def _translate_errors(fn):
31
+ """Without this, the MCP SDK treats any exception that isn't a ToolError as
32
+ a crash: the agent sees only the generic "Error executing tool <name>" and
33
+ the real message (e.g. SQLMesh's "Apply a plan first") is dropped, visible
34
+ only in server-side logs. SQLMeshError covers every error the underlying
35
+ library itself raises intentionally, so translating it is always safe to
36
+ show the agent -- it's exactly the informative half of the message.
37
+ """
38
+
39
+ @functools.wraps(fn)
40
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
41
+ try:
42
+ return fn(*args, **kwargs)
43
+ except SQLMeshError as e:
44
+ raise ToolError(str(e)) from e
45
+
46
+ return wrapper
47
+
48
+
49
+ def _snapshot_model_name(snapshot_id: Any) -> str:
50
+ """SnapshotId.name is a quoted, catalog-qualified identifier (e.g.
51
+ '"db"."sqlmesh_example"."seed_model"') -- normalize to the plain
52
+ 'schema.model' form every other tool here uses.
53
+ """
54
+ t = sqlglot.exp.to_table(snapshot_id.name)
55
+ return f"{t.db}.{t.name}" if t.db else t.name
56
+
57
+
58
+ def _model_summary(model: Any) -> dict:
59
+ return {
60
+ "name": model.name,
61
+ "kind": str(model.kind.name),
62
+ "description": model.description,
63
+ "owner": model.owner,
64
+ "tags": list(model.tags or []),
65
+ "columns": {col: str(dtype) for col, dtype in (model.columns_to_types or {}).items()},
66
+ }
67
+
68
+
69
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
70
+ @_translate_errors
71
+ def list_models() -> list[dict]:
72
+ """List every model in the SQLMesh project with its kind, columns, owner, and description."""
73
+ ctx = get_context()
74
+ return [_model_summary(m) for m in ctx.models.values()]
75
+
76
+
77
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
78
+ @_translate_errors
79
+ def get_model(model_name: str) -> dict:
80
+ """Full detail for one model: rendered query, columns, kind, owner, tags, description."""
81
+ ctx = get_context()
82
+ model = ctx.get_model(model_name, raise_if_missing=True)
83
+ summary = _model_summary(model)
84
+ summary["query"] = model.render_query_or_raise().sql(dialect=model.dialect, pretty=True)
85
+ return summary
86
+
87
+
88
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
89
+ @_translate_errors
90
+ def plan(environment: str | None = None, select_models: list[str] | None = None) -> dict:
91
+ """Preview what a plan against an environment would change. Does not apply anything.
92
+
93
+ Returns a plan_id -- pass it to apply_plan to actually apply this exact plan.
94
+ """
95
+ ctx = get_context()
96
+ p = ctx.plan(
97
+ environment=environment,
98
+ select_models=select_models,
99
+ no_prompts=True,
100
+ auto_apply=False,
101
+ )
102
+ _PLAN_CACHE[p.plan_id] = p
103
+ diff = p.context_diff
104
+ return {
105
+ "plan_id": p.plan_id,
106
+ "environment": p.environment_naming_info.name,
107
+ "has_changes": diff.has_changes,
108
+ "requires_backfill": p.requires_backfill,
109
+ "added_models": sorted(_snapshot_model_name(s) for s in diff.added),
110
+ "removed_models": sorted(_snapshot_model_name(s) for s in diff.removed_snapshots),
111
+ "modified_models": sorted(diff.modified_snapshots),
112
+ }
113
+
114
+
115
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True))
116
+ @_translate_errors
117
+ def apply_plan(plan_id: str, confirm: bool = False) -> dict:
118
+ """Apply a previously-previewed plan. THIS CHANGES REAL DATA in the target warehouse.
119
+
120
+ Requires confirm=true. plan_id must come from a plan() call in this same session --
121
+ plans aren't kept across server restarts.
122
+ """
123
+ if not confirm:
124
+ raise ToolError(
125
+ "Refusing to apply without confirm=true -- this changes real data "
126
+ "in the target warehouse."
127
+ )
128
+ p = _PLAN_CACHE.get(plan_id)
129
+ if p is None:
130
+ raise ToolError(
131
+ f"No cached plan with id {plan_id!r}. Call plan() again in this session first."
132
+ )
133
+ ctx = get_context()
134
+ ctx.apply(p)
135
+ del _PLAN_CACHE[plan_id]
136
+ return {"applied": True, "plan_id": plan_id}
137
+
138
+
139
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
140
+ @_translate_errors
141
+ def lineage(model_name: str, column: str) -> dict:
142
+ """Column-level lineage: which upstream models/columns does this column depend on."""
143
+ ctx = get_context()
144
+ deps = column_dependencies(ctx, model_name, column)
145
+ return {k: sorted(v) for k, v in deps.items()}
146
+
147
+
148
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
149
+ @_translate_errors
150
+ def run_audit(
151
+ model_name: str | None = None,
152
+ start: str | None = None,
153
+ end: str | None = None,
154
+ ) -> dict:
155
+ """Run audits for a model (or all models if omitted). start/end bound the data checked."""
156
+ ctx = get_context()
157
+ passed = ctx.audit(start=start, end=end, models=[model_name] if model_name else None)
158
+ return {"passed": passed, "model": model_name}
159
+
160
+
161
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
162
+ @_translate_errors
163
+ def run_test(model_name: str | None = None) -> dict:
164
+ """Run unit tests for a model (or all tests if omitted)."""
165
+ ctx = get_context()
166
+ result = ctx.test(model_names=[model_name] if model_name else None)
167
+ return {
168
+ "success": result.wasSuccessful(),
169
+ "tests_run": result.testsRun,
170
+ "failures": [str(f[0]) for f in result.failures],
171
+ "errors": [str(e[0]) for e in result.errors],
172
+ }
173
+
174
+
175
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
176
+ @_translate_errors
177
+ def diff_environment(environment: str) -> dict:
178
+ """Diff the current context against a target environment."""
179
+ ctx = get_context()
180
+ has_diff = ctx.diff(environment=environment)
181
+ return {"environment": environment, "has_diff": has_diff}
182
+
183
+
184
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True))
185
+ @_translate_errors
186
+ def list_environments() -> list[dict]:
187
+ """List every environment that exists in this project's state (e.g. prod, dev, ...)."""
188
+ ctx = get_context()
189
+ envs = ctx.state_reader.get_environments()
190
+ return [
191
+ {
192
+ "name": e.name,
193
+ "plan_id": e.plan_id,
194
+ "start_at": str(e.start_at) if e.start_at else None,
195
+ "end_at": str(e.end_at) if e.end_at else None,
196
+ "finalized_ts": e.finalized_ts,
197
+ "expiration_ts": e.expiration_ts,
198
+ }
199
+ for e in envs
200
+ ]
201
+
202
+
203
+ @server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True))
204
+ @_translate_errors
205
+ def run(
206
+ environment: str | None = None,
207
+ confirm: bool = False,
208
+ start: str | None = None,
209
+ end: str | None = None,
210
+ ) -> dict:
211
+ """Execute scheduled/due model runs for an environment. THIS CHANGES REAL DATA.
212
+
213
+ Distinct from plan/apply_plan: this runs already-promoted models for their
214
+ due intervals (what a cron trigger would do), rather than previewing or
215
+ promoting structural changes. Requires confirm=true.
216
+ """
217
+ if not confirm:
218
+ raise ToolError(
219
+ "Refusing to run without confirm=true -- this changes real data "
220
+ "in the target warehouse."
221
+ )
222
+ ctx = get_context()
223
+ status = ctx.run(environment=environment, start=start, end=end)
224
+ return {"status": status.name, "environment": environment}
225
+
226
+
227
+ def main() -> None:
228
+ server.run(transport="stdio")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
@@ -0,0 +1,126 @@
1
+ """End-to-end tests through the real MCP protocol, not just direct function calls.
2
+
3
+ These matter separately from test_server.py: calling the tool functions
4
+ directly and catching a Python exception proves our own logic is right, but
5
+ says nothing about what actually crosses the wire to an agent. This file
6
+ caught a real bug during development -- the MCP SDK silently replaces any
7
+ exception that isn't a ToolError with a generic "Error executing tool <name>"
8
+ (no further detail at all), dropping the actual message -- that no amount of
9
+ direct-call testing would have found. With ToolError, the SDK still prefixes
10
+ "Error executing tool <name>: ", but the real message now follows it.
11
+
12
+ Spawns the server as a real subprocess and talks to it over stdio, the same
13
+ way a real MCP client (Claude Desktop, etc.) would. Each test opens its own
14
+ session inline (rather than via a fixture) because sharing an async
15
+ generator fixture's stdio_client/ClientSession context managers across
16
+ pytest-asyncio's per-test task boundaries triggers anyio "cancel scope
17
+ exited in a different task" errors on teardown -- opening and closing the
18
+ session within a single test's task avoids that entirely.
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ from contextlib import asynccontextmanager
24
+ from pathlib import Path
25
+
26
+ from mcp import ClientSession
27
+ from mcp.client.stdio import StdioServerParameters, stdio_client
28
+
29
+ DEMO_PROJECT = Path(__file__).parent.parent / "examples" / "demo_project"
30
+
31
+
32
+ @asynccontextmanager
33
+ async def open_session():
34
+ env = os.environ.copy()
35
+ env["SQLMESH_PROJECT_PATH"] = str(DEMO_PROJECT)
36
+ params = StdioServerParameters(
37
+ command=sys.executable, args=["-m", "sqlmesh_mcp.server"], env=env
38
+ )
39
+ async with stdio_client(params) as (read, write):
40
+ async with ClientSession(read, write) as session:
41
+ await session.initialize()
42
+ yield session
43
+
44
+
45
+ async def test_lists_all_ten_tools():
46
+ async with open_session() as session:
47
+ tools = await session.list_tools()
48
+ names = {t.name for t in tools.tools}
49
+ assert names == {
50
+ "list_models",
51
+ "get_model",
52
+ "plan",
53
+ "apply_plan",
54
+ "lineage",
55
+ "run_audit",
56
+ "run_test",
57
+ "diff_environment",
58
+ "list_environments",
59
+ "run",
60
+ }
61
+
62
+
63
+ async def test_mutating_tools_are_flagged_destructive_and_not_read_only():
64
+ async with open_session() as session:
65
+ tools = {t.name: t for t in (await session.list_tools()).tools}
66
+ for name in ["apply_plan", "run"]:
67
+ assert tools[name].annotations.read_only_hint is False
68
+ assert tools[name].annotations.destructive_hint is True
69
+
70
+
71
+ async def test_read_only_tools_are_flagged_read_only():
72
+ async with open_session() as session:
73
+ tools = {t.name: t for t in (await session.list_tools()).tools}
74
+ for name in [
75
+ "list_models",
76
+ "get_model",
77
+ "plan",
78
+ "lineage",
79
+ "run_audit",
80
+ "run_test",
81
+ "diff_environment",
82
+ "list_environments",
83
+ ]:
84
+ assert tools[name].annotations.read_only_hint is True
85
+
86
+
87
+ async def test_list_models_call_round_trips_real_data():
88
+ async with open_session() as session:
89
+ result = await session.call_tool("list_models", {})
90
+ assert result.is_error is not True
91
+ assert "sqlmesh_example.full_model" in str(result.content)
92
+
93
+
94
+ async def test_apply_plan_without_confirm_is_a_tool_error_with_the_real_message():
95
+ """The regression this file exists to catch: without ToolError, this would
96
+ come back as only the generic "Error executing tool apply_plan" with no
97
+ further detail -- instead of that prefix followed by the actual "Refusing
98
+ to apply without confirm=true..." message the agent needs to see.
99
+ """
100
+ async with open_session() as session:
101
+ result = await session.call_tool("apply_plan", {"plan_id": "fake", "confirm": False})
102
+ assert result.is_error is True
103
+ text = str(result.content)
104
+ assert "confirm=true" in text
105
+ assert "changes real data in the target warehouse" in text
106
+
107
+
108
+ async def test_get_model_for_unknown_model_is_a_tool_error_with_the_real_message():
109
+ async with open_session() as session:
110
+ result = await session.call_tool("get_model", {"model_name": "does.not_exist"})
111
+ assert result.is_error is True
112
+ text = str(result.content)
113
+ assert "does.not_exist" in text or "does_not_exist" in text
114
+ assert "Cannot find model" in text
115
+
116
+
117
+ async def test_session_survives_a_tool_error_and_keeps_working():
118
+ """A tool error must not take down the server process or the session --
119
+ confirmed by making a real, successful call right after two failures.
120
+ """
121
+ async with open_session() as session:
122
+ await session.call_tool("apply_plan", {"plan_id": "fake", "confirm": False})
123
+ await session.call_tool("get_model", {"model_name": "does.not_exist"})
124
+
125
+ result = await session.call_tool("list_models", {})
126
+ assert result.is_error is not True
@@ -0,0 +1,192 @@
1
+ """Integration tests against the real demo SQLMesh project in examples/demo_project.
2
+
3
+ These call the underlying tool functions directly (not through the MCP
4
+ protocol layer -- see test_protocol.py for that) to keep them fast and
5
+ focused on our own logic.
6
+
7
+ See TEST_CASES.md for a plain-English index of every case covered here and
8
+ in test_protocol.py.
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from mcp.server.mcpserver.exceptions import ToolError
15
+
16
+ DEMO_PROJECT = Path(__file__).parent.parent / "examples" / "demo_project"
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def project_env(monkeypatch):
21
+ monkeypatch.setenv("SQLMESH_PROJECT_PATH", str(DEMO_PROJECT))
22
+ # get_context() is lru_cache'd; make sure each test gets a fresh Context
23
+ # bound to the env var set above rather than a stale cached one.
24
+ from sqlmesh_mcp.context import get_context
25
+
26
+ get_context.cache_clear()
27
+ yield
28
+ get_context.cache_clear()
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Read-only tools that don't require a prior plan/apply
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def test_list_models_returns_the_demo_project_models():
37
+ from sqlmesh_mcp.server import list_models
38
+
39
+ models = list_models()
40
+ names = {m["name"] for m in models}
41
+ assert "sqlmesh_example.full_model" in names
42
+ assert "sqlmesh_example.incremental_model" in names
43
+ assert "sqlmesh_example.seed_model" in names
44
+
45
+ full_model = next(m for m in models if m["name"] == "sqlmesh_example.full_model")
46
+ assert full_model["kind"] == "FULL"
47
+ assert "num_orders" in full_model["columns"]
48
+
49
+
50
+ def test_get_model_includes_rendered_query():
51
+ from sqlmesh_mcp.server import get_model
52
+
53
+ model = get_model("sqlmesh_example.incremental_model")
54
+ assert model["kind"] == "INCREMENTAL_BY_TIME_RANGE"
55
+ assert "seed_model" in model["query"]
56
+
57
+
58
+ def test_get_model_raises_tool_error_with_the_real_message_for_unknown_model():
59
+ from sqlmesh_mcp.server import get_model
60
+
61
+ with pytest.raises(ToolError, match="does_not_exist"):
62
+ get_model("sqlmesh_example.does_not_exist")
63
+
64
+
65
+ def test_lineage_traces_column_to_upstream_model():
66
+ from sqlmesh_mcp.server import lineage
67
+
68
+ deps = lineage("sqlmesh_example.full_model", "num_orders")
69
+ # num_orders = COUNT(DISTINCT id) from incremental_model -- id should show up
70
+ # somewhere in the dependency map's values.
71
+ all_upstream_cols = {col for cols in deps.values() for col in cols}
72
+ assert any("id" in col for col in all_upstream_cols) or any(
73
+ "incremental_model" in key for key in deps
74
+ )
75
+
76
+
77
+ def test_run_test_passes_on_the_untouched_demo_project():
78
+ from sqlmesh_mcp.server import run_test
79
+
80
+ result = run_test()
81
+ assert result["success"] is True
82
+ assert result["tests_run"] >= 1
83
+ assert result["failures"] == []
84
+ assert result["errors"] == []
85
+
86
+
87
+ def test_list_environments_is_empty_before_anything_is_applied():
88
+ from sqlmesh_mcp.server import list_environments
89
+
90
+ assert list_environments() == []
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # plan() / apply_plan() -- the two-step preview/confirm flow
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ def test_plan_previews_the_initial_environment_without_applying():
99
+ from sqlmesh_mcp.server import _PLAN_CACHE, plan
100
+
101
+ result = plan(environment="dev")
102
+ assert result["plan_id"]
103
+ assert result["environment"] == "dev"
104
+ # A brand new project planned against a fresh env should show every model as new,
105
+ # normalized to the same plain 'schema.model' form list_models/get_model use.
106
+ assert set(result["added_models"]) >= {
107
+ "sqlmesh_example.full_model",
108
+ "sqlmesh_example.incremental_model",
109
+ "sqlmesh_example.seed_model",
110
+ }
111
+ # Nothing should have been applied -- the plan is only cached, not run.
112
+ assert result["plan_id"] in _PLAN_CACHE
113
+
114
+
115
+ def test_apply_plan_refuses_without_confirm():
116
+ from sqlmesh_mcp.server import apply_plan, plan
117
+
118
+ p = plan(environment="dev_confirm_check")
119
+ with pytest.raises(ToolError, match="confirm=true"):
120
+ apply_plan(p["plan_id"], confirm=False)
121
+
122
+
123
+ def test_apply_plan_rejects_unknown_plan_id():
124
+ from sqlmesh_mcp.server import apply_plan
125
+
126
+ with pytest.raises(ToolError, match="No cached plan"):
127
+ apply_plan("not-a-real-plan-id", confirm=True)
128
+
129
+
130
+ def test_apply_plan_actually_applies_when_confirmed():
131
+ from sqlmesh_mcp.server import _PLAN_CACHE, apply_plan, plan
132
+
133
+ p = plan(environment="dev_apply_check")
134
+ result = apply_plan(p["plan_id"], confirm=True)
135
+ assert result["applied"] is True
136
+ assert p["plan_id"] not in _PLAN_CACHE
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Tools that only make sense once a plan has actually been applied --
141
+ # run_audit and diff_environment were previously completely untested; calling
142
+ # either against an unversioned project raises a SQLMeshError (confirmed
143
+ # manually: "Cannot audit ... it has not been versioned yet. Apply a plan
144
+ # first."), so these fixtures apply a real plan before exercising them.
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ @pytest.fixture
149
+ def applied_prod_env():
150
+ """Plans and applies against 'prod' so audits/diffs/runs have real, versioned data."""
151
+ from sqlmesh_mcp.server import apply_plan, plan
152
+
153
+ p = plan(environment="prod")
154
+ apply_plan(p["plan_id"], confirm=True)
155
+ return "prod"
156
+
157
+
158
+ def test_run_audit_passes_once_the_project_has_been_applied(applied_prod_env):
159
+ from sqlmesh_mcp.server import run_audit
160
+
161
+ result = run_audit()
162
+ assert result["passed"] is True
163
+
164
+
165
+ def test_diff_environment_shows_no_diff_immediately_after_apply(applied_prod_env):
166
+ from sqlmesh_mcp.server import diff_environment
167
+
168
+ result = diff_environment(applied_prod_env)
169
+ assert result == {"environment": "prod", "has_diff": False}
170
+
171
+
172
+ def test_list_environments_shows_the_applied_environment(applied_prod_env):
173
+ from sqlmesh_mcp.server import list_environments
174
+
175
+ envs = list_environments()
176
+ names = {e["name"] for e in envs}
177
+ assert "prod" in names
178
+
179
+
180
+ def test_run_reports_nothing_to_do_immediately_after_apply(applied_prod_env):
181
+ """apply_plan already backfilled every interval, so a run right after has nothing due."""
182
+ from sqlmesh_mcp.server import run
183
+
184
+ result = run(environment=applied_prod_env, confirm=True)
185
+ assert result["status"] == "NOTHING_TO_DO"
186
+
187
+
188
+ def test_run_refuses_without_confirm(applied_prod_env):
189
+ from sqlmesh_mcp.server import run
190
+
191
+ with pytest.raises(ToolError, match="confirm=true"):
192
+ run(environment=applied_prod_env, confirm=False)