sqlmesh-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sqlmesh_mcp/context.py ADDED
@@ -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)
sqlmesh_mcp/py.typed ADDED
File without changes
sqlmesh_mcp/server.py ADDED
@@ -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,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,9 @@
1
+ sqlmesh_mcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ sqlmesh_mcp/context.py,sha256=qyx2kSE_XNOp3CfclvgkQ4kq9YHsPFqzKE2K0sQlHEw,585
3
+ sqlmesh_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sqlmesh_mcp/server.py,sha256=TWeQMqhm8nba2IihZZ9gC64Hs0ksbkFQzxwo0C5arjI,8220
5
+ sqlmesh_mcp-0.1.0.dist-info/METADATA,sha256=GUfVZLo8W5PCbLlreREW2GxQc7v5U5ysIGcIZuXr29c,5100
6
+ sqlmesh_mcp-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ sqlmesh_mcp-0.1.0.dist-info/entry_points.txt,sha256=AwWqV3M1RwsYbprEzgo5scAktXnZHIOWaA3JSdINuHs,56
8
+ sqlmesh_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=cBM1LM6j5rzRZN9debz9be_EGSdSQ6xEBD13QBe0EHU,1070
9
+ sqlmesh_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlmesh-mcp = sqlmesh_mcp.server:main
@@ -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.