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.
@@ -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.
@@ -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 }
@@ -0,0 +1,7 @@
1
+ """Public surface of the semql-mcp package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from semql_mcp.server import Executor, MCPServer
6
+
7
+ __all__ = ["Executor", "MCPServer"]
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"]