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.
- sqlmesh_mcp-0.1.0/.github/workflows/ci.yml +21 -0
- sqlmesh_mcp-0.1.0/.github/workflows/publish.yml +42 -0
- sqlmesh_mcp-0.1.0/.gitignore +16 -0
- sqlmesh_mcp-0.1.0/CHANGELOG.md +19 -0
- sqlmesh_mcp-0.1.0/LICENSE +21 -0
- sqlmesh_mcp-0.1.0/PKG-INFO +125 -0
- sqlmesh_mcp-0.1.0/README.md +101 -0
- sqlmesh_mcp-0.1.0/TEST_CASES.md +58 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/audits/.gitkeep +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/audits/assert_positive_order_ids.sql +9 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/config.yaml +42 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/macros/.gitkeep +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/macros/__init__.py +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/models/.gitkeep +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/models/full_model.sql +15 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/models/incremental_model.sql +19 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/models/seed_model.sql +13 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/seeds/.gitkeep +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/seeds/seed_data.csv +8 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/tests/.gitkeep +0 -0
- sqlmesh_mcp-0.1.0/examples/demo_project/tests/test_full_model.yaml +19 -0
- sqlmesh_mcp-0.1.0/pyproject.toml +47 -0
- sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/__init__.py +1 -0
- sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/context.py +23 -0
- sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/py.typed +0 -0
- sqlmesh_mcp-0.1.0/src/sqlmesh_mcp/server.py +232 -0
- sqlmesh_mcp-0.1.0/tests/test_protocol.py +126 -0
- 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
|
+
[](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/sqlmesh-mcp/)
|
|
29
|
+
[](https://pypi.org/project/sqlmesh-mcp/)
|
|
30
|
+
[](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
|
+
[](https://github.com/Zain-ul-Abdin45/sqlmesh-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/sqlmesh-mcp/)
|
|
5
|
+
[](https://pypi.org/project/sqlmesh-mcp/)
|
|
6
|
+
[](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.
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
+
|
|
File without changes
|
|
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)
|