semql-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.
- semql_mcp-0.1.0/LICENSE +28 -0
- semql_mcp-0.1.0/PKG-INFO +171 -0
- semql_mcp-0.1.0/README.md +146 -0
- semql_mcp-0.1.0/pyproject.toml +38 -0
- semql_mcp-0.1.0/src/semql_mcp/__init__.py +7 -0
- semql_mcp-0.1.0/src/semql_mcp/py.typed +0 -0
- semql_mcp-0.1.0/src/semql_mcp/server.py +367 -0
semql_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Nikhil Pallamreddy
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
semql_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semql-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that wraps a semql Catalog — compile-only by default, opt-in row execution via a caller-provided executor.
|
|
5
|
+
Author: Nikhil Pallamreddy
|
|
6
|
+
Author-email: Nikhil Pallamreddy <nikhil.pallamreddy+git@gmail.com>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: fastmcp>=3.4.2
|
|
19
|
+
Requires-Dist: semql>=0.1.0,<0.2
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://github.com/npalladium/semql
|
|
22
|
+
Project-URL: Repository, https://github.com/npalladium/semql
|
|
23
|
+
Project-URL: Issues, https://github.com/npalladium/semql/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# semql-mcp
|
|
27
|
+
|
|
28
|
+
An MCP server that wraps a [`semql`](../semql) `Catalog` and exposes
|
|
29
|
+
its compiler / validator / prompt-renderer surfaces as tools any MCP
|
|
30
|
+
client can call. Built on [FastMCP](https://github.com/jlowin/fastmcp).
|
|
31
|
+
|
|
32
|
+
## Two modes
|
|
33
|
+
|
|
34
|
+
By default the server is **compile-only**. `semql` is a pure compiler —
|
|
35
|
+
no I/O — and this server keeps that contract. Tools return the emitted
|
|
36
|
+
SQL and bound parameters; the caller runs the SQL against whatever
|
|
37
|
+
backend they own.
|
|
38
|
+
|
|
39
|
+
Pass an `executor` at construction to opt into **exec mode**. A
|
|
40
|
+
`query_execute` tool registers in addition to the compile-only tools;
|
|
41
|
+
it runs the SQL against your executor and returns both the SQL/params
|
|
42
|
+
envelope and the resulting rows.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
pip install semql-mcp
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick start — compile-only
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from semql import Backend, Catalog, Cube, Dimension, Measure
|
|
54
|
+
from semql_mcp import MCPServer
|
|
55
|
+
|
|
56
|
+
catalog = Catalog([
|
|
57
|
+
Cube(
|
|
58
|
+
name="orders",
|
|
59
|
+
backend=Backend.POSTGRES,
|
|
60
|
+
table="orders",
|
|
61
|
+
alias="o",
|
|
62
|
+
measures=[Measure(name="revenue", sql="{o}.amount", agg="sum", unit="currency")],
|
|
63
|
+
dimensions=[Dimension(name="region", sql="{o}.region", type="string")],
|
|
64
|
+
),
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
server = MCPServer(catalog)
|
|
68
|
+
server.run(transport="stdio") # speak JSON-RPC over stdin/stdout
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick start — exec mode
|
|
72
|
+
|
|
73
|
+
Bring your own database driver and adapt its row shape to a list of
|
|
74
|
+
dicts:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import psycopg
|
|
78
|
+
from psycopg.rows import dict_row
|
|
79
|
+
|
|
80
|
+
from semql_mcp import MCPServer
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def executor(sql: str, params: dict) -> list[dict]:
|
|
84
|
+
with psycopg.connect("postgresql://...", row_factory=dict_row) as conn:
|
|
85
|
+
with conn.cursor() as cur:
|
|
86
|
+
cur.execute(sql, params)
|
|
87
|
+
return list(cur.fetchall())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
server = MCPServer(catalog, executor=executor)
|
|
91
|
+
server.run(transport="stdio")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The MCP server never imports a database driver. Whatever you wire in
|
|
95
|
+
is what gets called; semql-mcp just hands it `(sql, params)` and
|
|
96
|
+
expects `list[dict]` back.
|
|
97
|
+
|
|
98
|
+
## Tools
|
|
99
|
+
|
|
100
|
+
Always registered:
|
|
101
|
+
|
|
102
|
+
| Tool | Description |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `query_semantic(spec, context?)` | Compile a SemanticQuery; return `{backend, sql, params, columns}`. |
|
|
105
|
+
| `validate(spec)` | Collect-all static validation; returns `list[ValidationError]`. Empty when the query would compile cleanly. |
|
|
106
|
+
| `explain(spec, context?)` | Compile and return just the SQL string. |
|
|
107
|
+
| `catalog_prompt(only_exposed=True, include_introspection=False)` | Render the planner prompt fragment for the catalogue. |
|
|
108
|
+
|
|
109
|
+
Registered when `executor` is supplied:
|
|
110
|
+
|
|
111
|
+
| Tool | Description |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `query_execute(spec, context?)` | Compile + run. Returns the `query_semantic` shape plus `rows: list[dict]`. Errors carry the SQL we tried to run so callers can replay / inspect it. |
|
|
114
|
+
|
|
115
|
+
### Auto-generated per-cube tools
|
|
116
|
+
|
|
117
|
+
For each `expose_in_prompt=True` (non-META) cube, the server also
|
|
118
|
+
registers a `query_<cube_name>` tool whose `measures`, `dimensions`,
|
|
119
|
+
`order` (and `time_window.dimension`, when applicable) parameters are
|
|
120
|
+
**`Literal`-typed enums** of the cube's actual fields. The planner
|
|
121
|
+
sees a JSON Schema with explicit allowed values rather than the bare
|
|
122
|
+
`list[str]` `query_semantic` accepts.
|
|
123
|
+
|
|
124
|
+
Field names are **bare** (no cube prefix); the tool auto-qualifies as
|
|
125
|
+
it builds the `SemanticQuery`:
|
|
126
|
+
|
|
127
|
+
```jsonc
|
|
128
|
+
// query_orders
|
|
129
|
+
{
|
|
130
|
+
"measures": ["revenue"],
|
|
131
|
+
"dimensions": ["region"],
|
|
132
|
+
"filters": [{"dimension": "status", "op": "eq", "values": ["paid"]}],
|
|
133
|
+
"time_window": {
|
|
134
|
+
"dimension": "created_at",
|
|
135
|
+
"granularity": "day",
|
|
136
|
+
"range": ["2026-01-01", "2026-02-01"]
|
|
137
|
+
},
|
|
138
|
+
"limit": 100
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Multi-cube queries (joins across cubes) still go through
|
|
143
|
+
`query_semantic` — the per-cube tools are scoped to a single cube by
|
|
144
|
+
construction. When `executor` is configured, the per-cube tools
|
|
145
|
+
return rows too.
|
|
146
|
+
|
|
147
|
+
## In-process testing
|
|
148
|
+
|
|
149
|
+
FastMCP's `Client` connects to a `FastMCP` instance without a transport
|
|
150
|
+
— useful for end-to-end testing of your catalogue + planner together:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
import asyncio
|
|
154
|
+
from fastmcp import Client
|
|
155
|
+
from semql_mcp import MCPServer
|
|
156
|
+
|
|
157
|
+
server = MCPServer(catalog)
|
|
158
|
+
|
|
159
|
+
async def smoke() -> None:
|
|
160
|
+
async with Client(server.mcp) as c:
|
|
161
|
+
tools = await c.list_tools()
|
|
162
|
+
print([t.name for t in tools])
|
|
163
|
+
result = await c.call_tool("explain", {"spec": {"measures": ["orders.revenue"]}})
|
|
164
|
+
print(result.data)
|
|
165
|
+
|
|
166
|
+
asyncio.run(smoke())
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Status
|
|
170
|
+
|
|
171
|
+
Early development. The tool surface is stable.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# semql-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that wraps a [`semql`](../semql) `Catalog` and exposes
|
|
4
|
+
its compiler / validator / prompt-renderer surfaces as tools any MCP
|
|
5
|
+
client can call. Built on [FastMCP](https://github.com/jlowin/fastmcp).
|
|
6
|
+
|
|
7
|
+
## Two modes
|
|
8
|
+
|
|
9
|
+
By default the server is **compile-only**. `semql` is a pure compiler —
|
|
10
|
+
no I/O — and this server keeps that contract. Tools return the emitted
|
|
11
|
+
SQL and bound parameters; the caller runs the SQL against whatever
|
|
12
|
+
backend they own.
|
|
13
|
+
|
|
14
|
+
Pass an `executor` at construction to opt into **exec mode**. A
|
|
15
|
+
`query_execute` tool registers in addition to the compile-only tools;
|
|
16
|
+
it runs the SQL against your executor and returns both the SQL/params
|
|
17
|
+
envelope and the resulting rows.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install semql-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start — compile-only
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from semql import Backend, Catalog, Cube, Dimension, Measure
|
|
29
|
+
from semql_mcp import MCPServer
|
|
30
|
+
|
|
31
|
+
catalog = Catalog([
|
|
32
|
+
Cube(
|
|
33
|
+
name="orders",
|
|
34
|
+
backend=Backend.POSTGRES,
|
|
35
|
+
table="orders",
|
|
36
|
+
alias="o",
|
|
37
|
+
measures=[Measure(name="revenue", sql="{o}.amount", agg="sum", unit="currency")],
|
|
38
|
+
dimensions=[Dimension(name="region", sql="{o}.region", type="string")],
|
|
39
|
+
),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
server = MCPServer(catalog)
|
|
43
|
+
server.run(transport="stdio") # speak JSON-RPC over stdin/stdout
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick start — exec mode
|
|
47
|
+
|
|
48
|
+
Bring your own database driver and adapt its row shape to a list of
|
|
49
|
+
dicts:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import psycopg
|
|
53
|
+
from psycopg.rows import dict_row
|
|
54
|
+
|
|
55
|
+
from semql_mcp import MCPServer
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def executor(sql: str, params: dict) -> list[dict]:
|
|
59
|
+
with psycopg.connect("postgresql://...", row_factory=dict_row) as conn:
|
|
60
|
+
with conn.cursor() as cur:
|
|
61
|
+
cur.execute(sql, params)
|
|
62
|
+
return list(cur.fetchall())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
server = MCPServer(catalog, executor=executor)
|
|
66
|
+
server.run(transport="stdio")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The MCP server never imports a database driver. Whatever you wire in
|
|
70
|
+
is what gets called; semql-mcp just hands it `(sql, params)` and
|
|
71
|
+
expects `list[dict]` back.
|
|
72
|
+
|
|
73
|
+
## Tools
|
|
74
|
+
|
|
75
|
+
Always registered:
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `query_semantic(spec, context?)` | Compile a SemanticQuery; return `{backend, sql, params, columns}`. |
|
|
80
|
+
| `validate(spec)` | Collect-all static validation; returns `list[ValidationError]`. Empty when the query would compile cleanly. |
|
|
81
|
+
| `explain(spec, context?)` | Compile and return just the SQL string. |
|
|
82
|
+
| `catalog_prompt(only_exposed=True, include_introspection=False)` | Render the planner prompt fragment for the catalogue. |
|
|
83
|
+
|
|
84
|
+
Registered when `executor` is supplied:
|
|
85
|
+
|
|
86
|
+
| Tool | Description |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `query_execute(spec, context?)` | Compile + run. Returns the `query_semantic` shape plus `rows: list[dict]`. Errors carry the SQL we tried to run so callers can replay / inspect it. |
|
|
89
|
+
|
|
90
|
+
### Auto-generated per-cube tools
|
|
91
|
+
|
|
92
|
+
For each `expose_in_prompt=True` (non-META) cube, the server also
|
|
93
|
+
registers a `query_<cube_name>` tool whose `measures`, `dimensions`,
|
|
94
|
+
`order` (and `time_window.dimension`, when applicable) parameters are
|
|
95
|
+
**`Literal`-typed enums** of the cube's actual fields. The planner
|
|
96
|
+
sees a JSON Schema with explicit allowed values rather than the bare
|
|
97
|
+
`list[str]` `query_semantic` accepts.
|
|
98
|
+
|
|
99
|
+
Field names are **bare** (no cube prefix); the tool auto-qualifies as
|
|
100
|
+
it builds the `SemanticQuery`:
|
|
101
|
+
|
|
102
|
+
```jsonc
|
|
103
|
+
// query_orders
|
|
104
|
+
{
|
|
105
|
+
"measures": ["revenue"],
|
|
106
|
+
"dimensions": ["region"],
|
|
107
|
+
"filters": [{"dimension": "status", "op": "eq", "values": ["paid"]}],
|
|
108
|
+
"time_window": {
|
|
109
|
+
"dimension": "created_at",
|
|
110
|
+
"granularity": "day",
|
|
111
|
+
"range": ["2026-01-01", "2026-02-01"]
|
|
112
|
+
},
|
|
113
|
+
"limit": 100
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Multi-cube queries (joins across cubes) still go through
|
|
118
|
+
`query_semantic` — the per-cube tools are scoped to a single cube by
|
|
119
|
+
construction. When `executor` is configured, the per-cube tools
|
|
120
|
+
return rows too.
|
|
121
|
+
|
|
122
|
+
## In-process testing
|
|
123
|
+
|
|
124
|
+
FastMCP's `Client` connects to a `FastMCP` instance without a transport
|
|
125
|
+
— useful for end-to-end testing of your catalogue + planner together:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import asyncio
|
|
129
|
+
from fastmcp import Client
|
|
130
|
+
from semql_mcp import MCPServer
|
|
131
|
+
|
|
132
|
+
server = MCPServer(catalog)
|
|
133
|
+
|
|
134
|
+
async def smoke() -> None:
|
|
135
|
+
async with Client(server.mcp) as c:
|
|
136
|
+
tools = await c.list_tools()
|
|
137
|
+
print([t.name for t in tools])
|
|
138
|
+
result = await c.call_tool("explain", {"spec": {"measures": ["orders.revenue"]}})
|
|
139
|
+
print(result.data)
|
|
140
|
+
|
|
141
|
+
asyncio.run(smoke())
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Status
|
|
145
|
+
|
|
146
|
+
Early development. The tool surface is stable.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "semql-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server that wraps a semql Catalog — compile-only by default, opt-in row execution via a caller-provided executor."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Nikhil Pallamreddy", email = "nikhil.pallamreddy+git@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"fastmcp>=3.4.2",
|
|
14
|
+
"semql>=0.1.0,<0.2",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Database",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/npalladium/semql"
|
|
30
|
+
Repository = "https://github.com/npalladium/semql"
|
|
31
|
+
Issues = "https://github.com/npalladium/semql/issues"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.11.19,<0.12.0"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
semql = { workspace = true, editable = true }
|
|
File without changes
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
# pyright: reportUnusedFunction=false, reportUnknownParameterType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingParameterType=false, reportUnknownMemberType=false
|
|
2
|
+
# - reportUnusedFunction: FastMCP's @mcp.tool decorators register the
|
|
3
|
+
# wrapped function with the server; pyright sees the local name as
|
|
4
|
+
# "unused" because it can't follow the decorator's side effect.
|
|
5
|
+
# - reportUnknownParameterType / Argument / Variable / Missing: the
|
|
6
|
+
# per-cube tool factory builds dynamic ``Literal[...]`` annotations
|
|
7
|
+
# at runtime and attaches them via ``__annotations__``; the def
|
|
8
|
+
# itself intentionally has no static type hints.
|
|
9
|
+
"""MCP server wrapping a ``semql.Catalog``.
|
|
10
|
+
|
|
11
|
+
The server exposes the compiler / validator / prompt-renderer surfaces
|
|
12
|
+
as MCP tools so an LLM (or any MCP client) can plan and reason about
|
|
13
|
+
semantic queries against the catalogue.
|
|
14
|
+
|
|
15
|
+
By default the server is **compile-only**: ``semql`` is pure, so is
|
|
16
|
+
this server, and callers run the emitted SQL against whatever backend
|
|
17
|
+
they own. Pass an ``executor`` at construction to opt into row-returning
|
|
18
|
+
mode — a ``query_execute`` tool registers in addition to the
|
|
19
|
+
compile-only tools, runs the SQL against the executor, and returns
|
|
20
|
+
both the SQL and the rows. The executor is the only stateful surface
|
|
21
|
+
the server owns; everything else stays pure.
|
|
22
|
+
|
|
23
|
+
Tools always registered:
|
|
24
|
+
- ``query_semantic(spec, context?)`` — compile a SemanticQuery, return
|
|
25
|
+
``{sql, params, columns, backend}``.
|
|
26
|
+
- ``validate(spec)`` — collect-all static validation; returns a list
|
|
27
|
+
of ``ValidationError`` records.
|
|
28
|
+
- ``explain(spec, context?)`` — same as ``query_semantic`` but returns
|
|
29
|
+
just the SQL string.
|
|
30
|
+
- ``catalog_prompt(only_exposed=True, include_introspection=False)`` —
|
|
31
|
+
planner prompt fragment.
|
|
32
|
+
|
|
33
|
+
Registered only when ``executor`` is supplied:
|
|
34
|
+
- ``query_execute(spec, context?)`` — compile + run; returns the
|
|
35
|
+
``query_semantic`` shape plus ``rows: list[dict]``.
|
|
36
|
+
|
|
37
|
+
Transports: stdio (FastMCP default) plus anything FastMCP supports
|
|
38
|
+
out of the box. Use ``server.run(transport="stdio")`` for a
|
|
39
|
+
CLI-launched process, or pass ``server.mcp`` to a custom transport.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import asdict
|
|
46
|
+
from typing import Any, Literal
|
|
47
|
+
|
|
48
|
+
from fastmcp import FastMCP
|
|
49
|
+
from semql import Catalog
|
|
50
|
+
from semql.model import Cube
|
|
51
|
+
from semql.spec import Filter, SemanticQuery, TimeWindow
|
|
52
|
+
from semql.validate import ValidationError
|
|
53
|
+
from semql.validate import validate as validate_query
|
|
54
|
+
|
|
55
|
+
Transport = Literal["stdio", "http", "sse", "streamable-http"]
|
|
56
|
+
"""FastMCP transport identifiers."""
|
|
57
|
+
|
|
58
|
+
Executor = Callable[[str, dict[str, Any]], list[dict[str, Any]]]
|
|
59
|
+
"""``(sql, params) -> rows`` — sync executor surface.
|
|
60
|
+
|
|
61
|
+
Callers provide their own database driver (psycopg, clickhouse-connect,
|
|
62
|
+
DuckDB, ...) and adapt its row shape to a list of dicts. The MCP
|
|
63
|
+
server never imports a database driver itself."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MCPServer:
|
|
67
|
+
"""An MCP server exposing a SemQL ``Catalog`` to MCP clients.
|
|
68
|
+
|
|
69
|
+
The ``mcp`` attribute is the underlying ``FastMCP`` instance — pass
|
|
70
|
+
it to a ``fastmcp.Client`` for in-process testing, or call
|
|
71
|
+
``server.run(transport=...)`` to launch a real transport."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
catalog: Catalog,
|
|
76
|
+
*,
|
|
77
|
+
executor: Executor | None = None,
|
|
78
|
+
name: str = "semql",
|
|
79
|
+
) -> None:
|
|
80
|
+
self.catalog = catalog
|
|
81
|
+
self.executor = executor
|
|
82
|
+
self.mcp = FastMCP(name=name)
|
|
83
|
+
self._register_tools()
|
|
84
|
+
self._register_per_cube_tools()
|
|
85
|
+
|
|
86
|
+
def _register_tools(self) -> None:
|
|
87
|
+
catalog = self.catalog
|
|
88
|
+
executor = self.executor
|
|
89
|
+
|
|
90
|
+
@self.mcp.tool(
|
|
91
|
+
name="query_semantic",
|
|
92
|
+
description=(
|
|
93
|
+
"Compile a SemanticQuery against the catalogue and return "
|
|
94
|
+
"the emitted SQL, the bound parameters, and the output "
|
|
95
|
+
"column names. Pass ``context`` for ``{schema}`` / "
|
|
96
|
+
"``{ctx.X}`` substitution at compile time."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
def query_semantic(
|
|
100
|
+
spec: SemanticQuery,
|
|
101
|
+
context: dict[str, str] | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
try:
|
|
104
|
+
compiled = catalog.compile(spec, context=context)
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
return _error_payload(exc)
|
|
107
|
+
return {
|
|
108
|
+
"backend": compiled.backend.value,
|
|
109
|
+
"sql": compiled.sql,
|
|
110
|
+
"params": compiled.params,
|
|
111
|
+
"columns": compiled.columns,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@self.mcp.tool(
|
|
115
|
+
name="validate",
|
|
116
|
+
description=(
|
|
117
|
+
"Run collect-all static validation. Returns a list of "
|
|
118
|
+
"structured ValidationError records — empty when the "
|
|
119
|
+
"query would compile cleanly."
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
def validate(spec: SemanticQuery) -> list[dict[str, Any]]:
|
|
123
|
+
errors: list[ValidationError] = validate_query(spec, catalog)
|
|
124
|
+
return [asdict(e) for e in errors]
|
|
125
|
+
|
|
126
|
+
@self.mcp.tool(
|
|
127
|
+
name="explain",
|
|
128
|
+
description=(
|
|
129
|
+
"Compile a SemanticQuery and return just the SQL string. "
|
|
130
|
+
"Equivalent to ``query_semantic(...).sql`` — handy for "
|
|
131
|
+
"debugging 'what would you have run' without the params "
|
|
132
|
+
"envelope."
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
def explain(
|
|
136
|
+
spec: SemanticQuery,
|
|
137
|
+
context: dict[str, str] | None = None,
|
|
138
|
+
) -> str:
|
|
139
|
+
try:
|
|
140
|
+
compiled = catalog.compile(spec, context=context)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
return f"-- compile failed: {exc}"
|
|
143
|
+
return compiled.sql
|
|
144
|
+
|
|
145
|
+
@self.mcp.tool(
|
|
146
|
+
name="catalog_prompt",
|
|
147
|
+
description=(
|
|
148
|
+
"Render the planner prompt fragment for this catalogue — "
|
|
149
|
+
"what an LLM planner would see to learn the catalogue's "
|
|
150
|
+
"vocabulary and the SemanticQuery contract."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
def catalog_prompt(
|
|
154
|
+
only_exposed: bool = True,
|
|
155
|
+
include_introspection: bool = False,
|
|
156
|
+
) -> str:
|
|
157
|
+
return catalog.prompt(
|
|
158
|
+
only_exposed=only_exposed,
|
|
159
|
+
include_introspection=include_introspection,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if executor is not None:
|
|
163
|
+
|
|
164
|
+
@self.mcp.tool(
|
|
165
|
+
name="query_execute",
|
|
166
|
+
description=(
|
|
167
|
+
"Compile a SemanticQuery, execute it against the "
|
|
168
|
+
"configured database, and return both the SQL/params "
|
|
169
|
+
"envelope and the resulting rows. Available only when "
|
|
170
|
+
"the server was constructed with an ``executor``. "
|
|
171
|
+
"Errors from compile or execute surface as a "
|
|
172
|
+
"structured ``{error}`` payload."
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
def query_execute(
|
|
176
|
+
spec: SemanticQuery,
|
|
177
|
+
context: dict[str, str] | None = None,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
try:
|
|
180
|
+
compiled = catalog.compile(spec, context=context)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
return _error_payload(exc)
|
|
183
|
+
try:
|
|
184
|
+
rows = executor(compiled.sql, compiled.params)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
return _error_payload(exc) | {
|
|
187
|
+
"sql": compiled.sql,
|
|
188
|
+
"params": compiled.params,
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
"backend": compiled.backend.value,
|
|
192
|
+
"sql": compiled.sql,
|
|
193
|
+
"params": compiled.params,
|
|
194
|
+
"columns": compiled.columns,
|
|
195
|
+
"rows": rows,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def _register_per_cube_tools(self) -> None:
|
|
199
|
+
"""For each exposed, non-META cube, register a ``query_<cube>``
|
|
200
|
+
tool whose ``measures`` / ``dimensions`` / ``time_window.dimension``
|
|
201
|
+
parameters are ``Literal``-typed enums of the cube's actual
|
|
202
|
+
fields. Hidden cubes (``expose_in_prompt=False``) and META
|
|
203
|
+
reflection cubes are skipped — multi-cube and introspection
|
|
204
|
+
queries go through ``query_semantic``."""
|
|
205
|
+
from semql import iter_cubes
|
|
206
|
+
|
|
207
|
+
catalog = self.catalog
|
|
208
|
+
executor = self.executor
|
|
209
|
+
for cube in iter_cubes(catalog, only_exposed=True):
|
|
210
|
+
self.mcp.add_tool(_make_query_cube_tool(cube, catalog, executor))
|
|
211
|
+
|
|
212
|
+
def run(self, transport: Transport = "stdio", **kwargs: Any) -> None: # noqa: ANN401
|
|
213
|
+
"""Launch the server on ``transport``.
|
|
214
|
+
|
|
215
|
+
Defaults to stdio so a parent process can spawn the server and
|
|
216
|
+
speak JSON-RPC over its stdin/stdout. Forwards remaining kwargs
|
|
217
|
+
to FastMCP."""
|
|
218
|
+
self.mcp.run(transport=transport, **kwargs)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _error_payload(exc: Exception) -> dict[str, Any]:
|
|
222
|
+
"""Turn an exception into a structured tool response.
|
|
223
|
+
|
|
224
|
+
The MCP client should be able to surface the failure mode to the
|
|
225
|
+
planner; raising would just crash the tool call. ``code`` matches
|
|
226
|
+
SemQL's error-leaf class names so callers can branch on them
|
|
227
|
+
without parsing the message."""
|
|
228
|
+
return {
|
|
229
|
+
"error": {
|
|
230
|
+
"code": type(exc).__name__,
|
|
231
|
+
"message": str(exc),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _make_query_cube_tool(
|
|
237
|
+
cube: Cube,
|
|
238
|
+
catalog: Catalog,
|
|
239
|
+
executor: Executor | None,
|
|
240
|
+
) -> Callable[..., dict[str, Any]]:
|
|
241
|
+
"""Build a per-cube ``query_<cube>`` tool function.
|
|
242
|
+
|
|
243
|
+
The returned function has ``__name__`` set to ``query_<cube_name>``,
|
|
244
|
+
``__doc__`` set to the cube's description, and ``__annotations__``
|
|
245
|
+
set to typed signatures whose ``measures`` / ``dimensions`` /
|
|
246
|
+
``time_window.dimension`` are ``Literal``-typed enums of the cube's
|
|
247
|
+
actual field names. FastMCP reads those via ``inspect.signature``
|
|
248
|
+
to generate a JSON Schema with the enum constraint.
|
|
249
|
+
|
|
250
|
+
The body auto-prefixes the bare field names with ``cube.name.`` so
|
|
251
|
+
the planner doesn't have to repeat the cube name."""
|
|
252
|
+
cube_name = cube.name
|
|
253
|
+
measure_names = tuple(m.name for m in cube.measures)
|
|
254
|
+
dimension_names = tuple(d.name for d in cube.dimensions)
|
|
255
|
+
time_dim_names = tuple(td.name for td in cube.time_dimensions)
|
|
256
|
+
field_names = (*measure_names, *dimension_names, *time_dim_names)
|
|
257
|
+
|
|
258
|
+
# Build Literal types at runtime. ``Literal[("a", "b")]`` syntax is
|
|
259
|
+
# supported in Python 3.11+ via the subscription protocol. The
|
|
260
|
+
# types are attached to the function's ``__annotations__`` below —
|
|
261
|
+
# the ``def`` itself can't reference them directly because this
|
|
262
|
+
# module uses ``from __future__ import annotations`` (annotations
|
|
263
|
+
# would be unresolvable string forms).
|
|
264
|
+
measure_t = list[Literal[measure_names]] if measure_names else list[str] # type: ignore[valid-type]
|
|
265
|
+
dim_t = list[Literal[dimension_names]] if dimension_names else list[str] # type: ignore[valid-type]
|
|
266
|
+
field_t = Literal[field_names] if field_names else str
|
|
267
|
+
order_t = list[tuple[field_t, Literal["asc", "desc"]]] # type: ignore[valid-type]
|
|
268
|
+
|
|
269
|
+
def query_cube_fn( # type: ignore[no-untyped-def] # noqa: ANN202 — signature attached via __annotations__ below
|
|
270
|
+
measures=None, # noqa: ANN001
|
|
271
|
+
dimensions=None, # noqa: ANN001
|
|
272
|
+
filters=None, # noqa: ANN001
|
|
273
|
+
time_window=None, # noqa: ANN001
|
|
274
|
+
having=None, # noqa: ANN001
|
|
275
|
+
order=None, # noqa: ANN001
|
|
276
|
+
limit=None, # noqa: ANN001
|
|
277
|
+
offset=None, # noqa: ANN001
|
|
278
|
+
ungrouped=False, # noqa: ANN001
|
|
279
|
+
context=None, # noqa: ANN001
|
|
280
|
+
):
|
|
281
|
+
try:
|
|
282
|
+
spec = SemanticQuery(
|
|
283
|
+
measures=[f"{cube_name}.{m}" for m in (measures or [])],
|
|
284
|
+
dimensions=[f"{cube_name}.{d}" for d in (dimensions or [])],
|
|
285
|
+
filters=[
|
|
286
|
+
Filter(
|
|
287
|
+
dimension=_prefix(f.dimension, cube_name),
|
|
288
|
+
op=f.op,
|
|
289
|
+
values=f.values,
|
|
290
|
+
)
|
|
291
|
+
for f in (filters or [])
|
|
292
|
+
],
|
|
293
|
+
time_dimension=_prefix_time_window(time_window, cube_name),
|
|
294
|
+
having=[
|
|
295
|
+
Filter(dimension=h.dimension, op=h.op, values=h.values) for h in (having or [])
|
|
296
|
+
],
|
|
297
|
+
order=[(o[0], o[1]) for o in (order or [])],
|
|
298
|
+
limit=limit,
|
|
299
|
+
offset=offset,
|
|
300
|
+
ungrouped=ungrouped,
|
|
301
|
+
)
|
|
302
|
+
except Exception as exc:
|
|
303
|
+
return _error_payload(exc)
|
|
304
|
+
try:
|
|
305
|
+
compiled = catalog.compile(spec, context=context)
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
return _error_payload(exc)
|
|
308
|
+
envelope: dict[str, Any] = {
|
|
309
|
+
"backend": compiled.backend.value,
|
|
310
|
+
"sql": compiled.sql,
|
|
311
|
+
"params": compiled.params,
|
|
312
|
+
"columns": compiled.columns,
|
|
313
|
+
}
|
|
314
|
+
if executor is None:
|
|
315
|
+
return envelope
|
|
316
|
+
try:
|
|
317
|
+
envelope["rows"] = executor(compiled.sql, compiled.params)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
return _error_payload(exc) | envelope
|
|
320
|
+
return envelope
|
|
321
|
+
|
|
322
|
+
query_cube_fn.__name__ = f"query_{cube_name}"
|
|
323
|
+
query_cube_fn.__doc__ = (cube.description or f"Query the {cube_name} cube.") + (
|
|
324
|
+
f"\n\nMeasures: {', '.join(measure_names) or '(none)'}."
|
|
325
|
+
f"\nDimensions: {', '.join(dimension_names) or '(none)'}."
|
|
326
|
+
+ (f"\nTime dimensions: {', '.join(time_dim_names)}." if time_dim_names else "")
|
|
327
|
+
+ "\n\nField names are bare (no cube prefix); the tool "
|
|
328
|
+
"auto-qualifies them as it builds the SemanticQuery."
|
|
329
|
+
)
|
|
330
|
+
query_cube_fn.__annotations__ = {
|
|
331
|
+
"measures": measure_t | None,
|
|
332
|
+
"dimensions": dim_t | None,
|
|
333
|
+
"filters": list[Filter] | None,
|
|
334
|
+
"time_window": TimeWindow | None,
|
|
335
|
+
"having": list[Filter] | None,
|
|
336
|
+
"order": order_t | None,
|
|
337
|
+
"limit": int | None,
|
|
338
|
+
"offset": int | None,
|
|
339
|
+
"ungrouped": bool,
|
|
340
|
+
"context": dict[str, str] | None,
|
|
341
|
+
"return": dict[str, Any],
|
|
342
|
+
}
|
|
343
|
+
return query_cube_fn
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _prefix(name: str, cube_name: str) -> str:
|
|
347
|
+
"""Auto-prefix a bare field name with the cube name.
|
|
348
|
+
|
|
349
|
+
If the caller already qualified the name (``orders.region``), pass
|
|
350
|
+
it through unchanged so cross-cube references in filters/having
|
|
351
|
+
still work."""
|
|
352
|
+
if "." in name:
|
|
353
|
+
return name
|
|
354
|
+
return f"{cube_name}.{name}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _prefix_time_window(tw: TimeWindow | None, cube_name: str) -> TimeWindow | None:
|
|
358
|
+
if tw is None:
|
|
359
|
+
return None
|
|
360
|
+
return TimeWindow(
|
|
361
|
+
dimension=_prefix(tw.dimension, cube_name),
|
|
362
|
+
granularity=tw.granularity,
|
|
363
|
+
range=tw.range,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
__all__ = ["Executor", "MCPServer"]
|