lexis-cli 0.4.2__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.
- lexis/__init__.py +10 -0
- lexis/_vendor/ossie/NOTICE.md +14 -0
- lexis/_vendor/ossie/__init__.py +44 -0
- lexis/_vendor/ossie/models.py +224 -0
- lexis/cli.py +298 -0
- lexis/demo_data.py +87 -0
- lexis/dispatch.py +66 -0
- lexis/mcp_server.py +158 -0
- lexis/parser.py +19 -0
- lexis/resolved_model.py +128 -0
- lexis/retail_demo_data.py +615 -0
- lexis/sml/__init__.py +10 -0
- lexis/sml/_common.py +224 -0
- lexis/sml/emit.py +403 -0
- lexis/sml/models.py +172 -0
- lexis/sml/parse.py +382 -0
- lexis/transpilers/base.py +11 -0
- lexis/transpilers/cube.py +119 -0
- lexis/transpilers/dbt_ossie.py +67 -0
- lexis/transpilers/mcp.py +182 -0
- lexis/transpilers/snowflake_semantic_view.py +126 -0
- lexis/transpilers/sql/__init__.py +22 -0
- lexis/transpilers/sql/base.py +172 -0
- lexis/transpilers/sql/bigquery.py +8 -0
- lexis/transpilers/sql/databricks.py +8 -0
- lexis/transpilers/sql/duckdb.py +8 -0
- lexis/transpilers/sql/postgres.py +10 -0
- lexis/transpilers/sql/snowflake.py +8 -0
- lexis_cli-0.4.2.dist-info/METADATA +883 -0
- lexis_cli-0.4.2.dist-info/RECORD +33 -0
- lexis_cli-0.4.2.dist-info/WHEEL +4 -0
- lexis_cli-0.4.2.dist-info/entry_points.txt +2 -0
- lexis_cli-0.4.2.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lexis-cli
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Ossie-native semantic layer: author models once, transpile to warehouse-native SQL and BI/AI consumer formats
|
|
5
|
+
Project-URL: Homepage, https://github.com/PuspenduBanerjee/Lexis
|
|
6
|
+
Project-URL: Repository, https://github.com/PuspenduBanerjee/Lexis
|
|
7
|
+
Project-URL: Issues, https://github.com/PuspenduBanerjee/Lexis/issues
|
|
8
|
+
Author: Puspendu Banerjee
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Provides-Extra: api
|
|
26
|
+
Requires-Dist: alembic>=1.13; extra == 'api'
|
|
27
|
+
Requires-Dist: duckdb>=1.0; extra == 'api'
|
|
28
|
+
Requires-Dist: fastapi>=0.115; extra == 'api'
|
|
29
|
+
Requires-Dist: httpx>=0.27; extra == 'api'
|
|
30
|
+
Requires-Dist: mcp<2,>=1.9; extra == 'api'
|
|
31
|
+
Requires-Dist: pydantic-settings>=2.0; extra == 'api'
|
|
32
|
+
Requires-Dist: python-multipart>=0.0.9; extra == 'api'
|
|
33
|
+
Requires-Dist: snowflake-connector-python>=3.12; extra == 'api'
|
|
34
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'api'
|
|
35
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == 'api'
|
|
36
|
+
Provides-Extra: dev
|
|
37
|
+
Requires-Dist: duckdb>=1.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: httpx2>=2.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: hypothesis>=6.100; extra == 'dev'
|
|
40
|
+
Requires-Dist: jsonschema>=4.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
42
|
+
Provides-Extra: mcp
|
|
43
|
+
Requires-Dist: anyio>=4.0; extra == 'mcp'
|
|
44
|
+
Requires-Dist: duckdb>=1.0; extra == 'mcp'
|
|
45
|
+
Requires-Dist: mcp<2,>=1.9; extra == 'mcp'
|
|
46
|
+
Requires-Dist: snowflake-connector-python>=3.12; extra == 'mcp'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# Lexis
|
|
50
|
+
|
|
51
|
+
An open, [Apache Ossie](https://github.com/apache/ossie)-native semantic layer:
|
|
52
|
+
author a data model once in Ossie YAML, then transpile it to warehouse-native SQL
|
|
53
|
+
(Snowflake, BigQuery, Databricks, DuckDB, Postgres) and to formats BI/AI consumers
|
|
54
|
+
understand (Cube.js schema, dbt-core Ossie documents, MCP tool manifests grounded in
|
|
55
|
+
`ai_context`).
|
|
56
|
+
|
|
57
|
+
Two ways to use it: a `lexis` CLI/library, and a web UI (FastAPI + React) with a
|
|
58
|
+
persisted multi-model workspace, role-based access, and live semantic query execution.
|
|
59
|
+
|
|
60
|
+
See [docs/architecture-plan.md](docs/architecture-plan.md) for the full architecture
|
|
61
|
+
writeup and design rationale.
|
|
62
|
+
|
|
63
|
+
This repo tracks the upstream [Ossie spec](https://github.com/apache/ossie)
|
|
64
|
+
as a git submodule at `third_party/ossie` (schema, converters docs, examples) so our
|
|
65
|
+
vendored model classes (`src/lexis/_vendor/ossie/`) can be kept in sync with it -
|
|
66
|
+
see "Keeping Ossie in sync" below.
|
|
67
|
+
|
|
68
|
+
## Quickstart: Install from PyPI
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install lexis-cli
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Save this as `model.yaml`:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
version: "0.2.0.dev0"
|
|
78
|
+
semantic_model:
|
|
79
|
+
- name: shop
|
|
80
|
+
datasets:
|
|
81
|
+
- name: orders
|
|
82
|
+
source: analytics.public.orders
|
|
83
|
+
fields:
|
|
84
|
+
- name: amount
|
|
85
|
+
expression:
|
|
86
|
+
dialects:
|
|
87
|
+
- dialect: ANSI_SQL
|
|
88
|
+
expression: amount
|
|
89
|
+
metrics:
|
|
90
|
+
- name: total_revenue
|
|
91
|
+
expression:
|
|
92
|
+
dialects:
|
|
93
|
+
- dialect: ANSI_SQL
|
|
94
|
+
expression: SUM(orders.amount)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then transpile it:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
lexis transpile model.yaml --target duckdb --metric total_revenue
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```sql
|
|
104
|
+
SELECT SUM(orders.amount) AS "total_revenue"
|
|
105
|
+
FROM analytics.public.orders AS "orders"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Same command works for every target below — see [Quickstart: CLI (from source)](#quickstart-cli-from-source) for the full target list and the repo's own TPC-DS-based example fixture.
|
|
109
|
+
|
|
110
|
+
## Quickstart: CLI (from source)
|
|
111
|
+
|
|
112
|
+
Requires Python 3.11+.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git submodule update --init # first time only, or after a fresh clone
|
|
116
|
+
pip install -e .
|
|
117
|
+
lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target duckdb --metric total_sales
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```sql
|
|
121
|
+
SELECT SUM(store_sales.ss_ext_sales_price) AS "total_sales"
|
|
122
|
+
FROM tpcds.public.store_sales AS "store_sales"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Other targets: `postgres`, `bigquery`, `databricks`, `snowflake` (all take `--metric`,
|
|
126
|
+
and an optional repeatable `--group-by dataset.field`), plus `cube`, `dbt`, `mcp`, and
|
|
127
|
+
`snowflake_semantic_view` (whole-model outputs, no `--metric` needed):
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target mcp
|
|
131
|
+
lexis transpile tests/fixtures/tpcds_semantic_model.yaml \
|
|
132
|
+
--target duckdb --metric customer_lifetime_value --group-by item.i_category
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`snowflake_semantic_view` emits a `CREATE OR REPLACE SEMANTIC VIEW` DDL statement
|
|
136
|
+
(Snowflake's native Cortex Analyst semantic view) with `TABLES`/`RELATIONSHIPS`/
|
|
137
|
+
`FACTS`/`DIMENSIONS`/`METRICS` clauses built from the model's datasets, relationships,
|
|
138
|
+
fields, and metrics — fields with a `dimension` block become `DIMENSIONS`, fields
|
|
139
|
+
without one become `FACTS`, and `ai_context` synonyms/descriptions map to `WITH
|
|
140
|
+
SYNONYMS`/`COMMENT`:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
lexis transpile tests/fixtures/tpcds_semantic_model.yaml --target snowflake_semantic_view
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Add `--out <file>` to write to a file instead of stdout.
|
|
147
|
+
|
|
148
|
+
A bundled demo dataset (the same data the web UI's "Demo dataset" run mode uses
|
|
149
|
+
in-memory) can be exported to a real `.duckdb` file, handy as a seed file for the
|
|
150
|
+
Upload run mode or a `duckdb_file` connection:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[mcp]" # needs the optional duckdb dependency
|
|
154
|
+
lexis export-demo-dataset --out demo.duckdb # small TPC-DS fixture (default)
|
|
155
|
+
lexis export-demo-dataset --dataset retail --out retail.duckdb # 10,000-fact retail analytics dataset
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Pass `--force` to overwrite an existing file at `--out`. See
|
|
159
|
+
[The bundled demo datasets](#the-bundled-demo-datasets) below for what each one contains.
|
|
160
|
+
|
|
161
|
+
## The bundled demo datasets
|
|
162
|
+
|
|
163
|
+
Lexis ships two in-memory demo datasets so any model can be run for real without a
|
|
164
|
+
warehouse. The web UI's and API's **"Demo dataset"** run mode picks whichever one
|
|
165
|
+
matches the model's `source` catalog automatically; the CLI (`export-demo-dataset`)
|
|
166
|
+
selects it with `--dataset`.
|
|
167
|
+
|
|
168
|
+
| `--dataset` | Bundled model | Catalog | Size | Best for |
|
|
169
|
+
|---|---|---|---|---|
|
|
170
|
+
| `tpcds` *(default)* | `tpcds_retail_model` — [`tests/fixtures/tpcds_semantic_model.yaml`](tests/fixtures/tpcds_semantic_model.yaml) | `tpcds.public.*` | 7 `store_sales` rows, 2 items, 2 customers, 6 dates | small deterministic examples, emitter/JOIN behaviour |
|
|
171
|
+
| `retail` | `retail_analytics` — [`src/lexis_api/sample_data/retail_analytics_model.yaml`](src/lexis_api/sample_data/retail_analytics_model.yaml) | `retail.public.*` | **10,000 sales facts** + 1,500 returns, 800 customers, 300 items, 25 stores, 40 promotions, 3 years of dates | realistic analytics — segmentation, seasonality, basket/AOV, promo lift, returns |
|
|
172
|
+
|
|
173
|
+
### The `retail` dataset
|
|
174
|
+
|
|
175
|
+
A multi-fact star schema: two fact tables sharing five conformed dimensions, all
|
|
176
|
+
generated from a fixed seed ([`src/lexis/retail_demo_data.py`](src/lexis/retail_demo_data.py)),
|
|
177
|
+
so every number is reproducible.
|
|
178
|
+
|
|
179
|
+
| Table | Rows | Notable columns |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `fct_store_sales` | 10,000 (one per sold line item; a basket shares `ss_ticket_number`) | `ss_ext_sales_price`, `ss_quantity`, `ss_net_paid`, `ss_ext_wholesale_cost`, `ss_ext_discount_amt` |
|
|
182
|
+
| `fct_store_returns` | 1,500 | `sr_return_amt`, `sr_return_quantity`, `sr_net_loss`, `sr_reason` |
|
|
183
|
+
| `dim_date` | 1,096 (2022-01-01 … 2024-12-31) | `d_date_sk` = `YYYYMMDD`, `d_year`, `d_quarter_name`, `d_month_name`, `d_is_weekend`, `d_holiday_name` |
|
|
184
|
+
| `dim_customer` | 800 | `c_gender`, `c_age_band`, `c_income_band`, `c_education_status`, `c_loyalty_tier`, `c_preferred_channel`, `c_state` |
|
|
185
|
+
| `dim_item` | 300 | `i_category` (10) → `i_class` (6 each) → `i_brand` (40), `i_manufacturer`, `i_color`, `i_size` |
|
|
186
|
+
| `dim_store` | 25 | `s_store_type` (Flagship/Standard/Express/Outlet), `s_number_employees`, `s_floor_space`, `s_division_name` |
|
|
187
|
+
| `dim_promotion` | 41 | `p_channel`, `p_discount_pct`; `p_promo_sk = 0` is the "No Promotion" member |
|
|
188
|
+
|
|
189
|
+
Sales are deliberately skewed toward recent years, Q4, and weekends, so time-series
|
|
190
|
+
and seasonality queries show a real shape. The `retail_analytics` model exposes 17
|
|
191
|
+
metrics over it (`total_revenue`, `gross_margin_pct`, `units_sold`,
|
|
192
|
+
`transaction_count`, `avg_basket_value`, `distinct_customers`, `discount_rate_pct`,
|
|
193
|
+
`sales_per_employee`, `return_amount`, `net_loss_from_returns`, …). No single metric
|
|
194
|
+
spans both fact tables — query sales and returns separately.
|
|
195
|
+
|
|
196
|
+
**From the CLI:**
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pip install -e ".[mcp]" # needs the optional duckdb dependency
|
|
200
|
+
|
|
201
|
+
# transpile one metric, grouped by a dimension attribute
|
|
202
|
+
lexis transpile src/lexis_api/sample_data/retail_analytics_model.yaml \
|
|
203
|
+
--target duckdb --metric total_revenue --group-by dim_item.i_category
|
|
204
|
+
|
|
205
|
+
# export the data to a real .duckdb file (reuse it in Upload mode or a duckdb_file connection)
|
|
206
|
+
lexis export-demo-dataset --dataset retail --out retail-demo.duckdb
|
|
207
|
+
|
|
208
|
+
# serve the retail model's 17 metrics as live MCP tools against this data
|
|
209
|
+
lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**In the web UI:** `retail_analytics` is preloaded on first run. Open it → **Test
|
|
213
|
+
Metrics** tab → keep **Demo dataset** mode → pick a metric (optionally "Group by" an
|
|
214
|
+
item / store / customer / promotion / date attribute) and **Run**, or switch to
|
|
215
|
+
**Time series** for a year → quarter → month → day drill-down on `dim_date.d_date`.
|
|
216
|
+
The **Export demo dataset (.duckdb)** button downloads exactly this data.
|
|
217
|
+
|
|
218
|
+
**Over the remote MCP endpoint:** the mounted `/mcp` endpoint has no demo mode, so
|
|
219
|
+
register the exported file as a connection first, then point a client at the retail
|
|
220
|
+
model's id:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
lexis export-demo-dataset --dataset retail --out /tmp/retail-demo.duckdb --force
|
|
224
|
+
curl -X POST http://localhost:8000/api/connections \
|
|
225
|
+
-H "X-Account-Id: 2" -H "Content-Type: application/json" \
|
|
226
|
+
-d '{"name":"retail-demo","type":"duckdb_file","config":{"path":"/tmp/retail-demo.duckdb"}}'
|
|
227
|
+
# -> use the returned "id" as connection_id on /api/models/<retail-model-id>/mcp
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Quickstart: Web UI
|
|
231
|
+
|
|
232
|
+
Two servers: a FastAPI backend and a Vite/React frontend.
|
|
233
|
+
|
|
234
|
+
**Backend** (from the repo root):
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
pip install -e ".[dev,api]"
|
|
238
|
+
alembic upgrade head # creates lexis_dev.db and its schema
|
|
239
|
+
uvicorn lexis_api.main:app --reload --port 8000
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Startup automatically seeds 3 demo users (`admin`, `editor1`, `viewer1` — ids 1/2/3,
|
|
243
|
+
roles Admin/Editor/Viewer). There's no login screen yet: requests are attributed to a
|
|
244
|
+
user via an `X-Account-Id` header (defaults to `1`/admin if omitted) — a deliberate stub,
|
|
245
|
+
see [docs/architecture-plan.md](docs/architecture-plan.md) for why and what a real
|
|
246
|
+
auth swap-in looks like.
|
|
247
|
+
|
|
248
|
+
**Frontend** (in a second terminal):
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
cd frontend
|
|
252
|
+
npm install
|
|
253
|
+
npm run dev # http://localhost:5173, proxies /api -> :8000
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Or run both together with one script, from the repo root (needs `uvicorn`/`alembic`
|
|
257
|
+
on PATH already, e.g. via the pyenv/venv `pip install -e ".[dev,api]"` above):
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
./scripts/dev.sh start # runs migrations, launches both, backgrounded
|
|
261
|
+
./scripts/dev.sh status # is either running, and which pid
|
|
262
|
+
./scripts/dev.sh stop # stops both (and their child processes)
|
|
263
|
+
./scripts/dev.sh restart
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Set `LEXIS_DEV_SETUP_DEMO=1` (env var, honoured by the backend however it's
|
|
267
|
+
launched) to skip the manual `curl` above: on startup the API writes the bundled
|
|
268
|
+
demo datasets to `tpcds-demo.duckdb` / `retail-demo.duckdb` in the system temp dir
|
|
269
|
+
(`/tmp` on Linux) and registers a `duckdb_file` connection for each (`tpcds-demo`,
|
|
270
|
+
`retail-demo`), so the MCP endpoint and Run tab work immediately. Both steps are
|
|
271
|
+
idempotent and self-heal after a reboot clears the temp dir. Relocate the files
|
|
272
|
+
with `LEXIS_DEMO_DATA_DIR`. Leave the flag off in production.
|
|
273
|
+
|
|
274
|
+
`./scripts/demo-dev.sh <start|stop|restart|status>` is a wrapper that runs
|
|
275
|
+
`dev.sh` with `LEXIS_DEV_SETUP_DEMO=1` and `LEXIS_DEV_UI_PROXY=1` (single-port:
|
|
276
|
+
the backend also serves the UI) preset — one command for a demo/tunnel setup.
|
|
277
|
+
|
|
278
|
+
Logs go to `.dev/api.log` / `.dev/web.log`; override ports with `LEXIS_API_PORT`/
|
|
279
|
+
`LEXIS_WEB_PORT` env vars. The API writes one line per request to its log
|
|
280
|
+
(`METHOD /path -> status`), including the `X-User-Email` / `X-User-Id` /
|
|
281
|
+
`X-User-Name` headers — if a tunnel's OAuth traffic policy injects them from the
|
|
282
|
+
authenticated identity, they show up here; `-` otherwise.
|
|
283
|
+
|
|
284
|
+
Open `http://localhost:5173`, use the "Acting as" switcher in the header to pick a
|
|
285
|
+
role, paste an Ossie YAML document (e.g. `tests/fixtures/tpcds_semantic_model.yaml`) to
|
|
286
|
+
create a model, then use the **Browse** / **Design** / **Transpile** / **Test Metrics**
|
|
287
|
+
tabs on the model's page (two sample models are preloaded automatically on first run,
|
|
288
|
+
so there's already something to open: `tpcds_retail_model` on the small TPC-DS
|
|
289
|
+
fixture, and `retail_analytics`, a larger multi-fact star schema backed by a
|
|
290
|
+
10,000-fact generated demo dataset). "Design" is a node-graph canvas (owner/admin only)
|
|
291
|
+
for visually adding/editing datasets, fields, and relationships — drag between the
|
|
292
|
+
dots on a dataset box to draw a relationship. Metrics appear as their own node,
|
|
293
|
+
connected by dashed edges to every dataset their expression references (a "Show
|
|
294
|
+
metrics" toggle hides them); "+ Add metric" creates one, and clicking a metric opens
|
|
295
|
+
a panel to edit its expression/description or delete it (name is fixed after
|
|
296
|
+
creation, like a dataset's) — owner/admin only, same as the rest of the Design tab. A
|
|
297
|
+
metric's panel also shows a live **time-series preview** (against the demo dataset,
|
|
298
|
+
reflecting the last *saved* version) when the model has any field marked
|
|
299
|
+
`dimension.is_time: true` — click a row to drill into the next finer grain (year →
|
|
300
|
+
quarter → month → day), or "Roll up" to go back.
|
|
301
|
+
The canvas preserves anything it has no control for (`ai_context`, `custom_extensions`,
|
|
302
|
+
non-ANSI_SQL dialect expressions) by merging onto the existing
|
|
303
|
+
parsed model rather than regenerating YAML from scratch; see
|
|
304
|
+
`src/lexis_api/graph_edit.py`. "Test Metrics" executes the generated SQL for real,
|
|
305
|
+
against the model's bundled demo dataset (TPC-DS or retail analytics, chosen from the
|
|
306
|
+
model's source catalog), an uploaded `.duckdb`/`.db` file, or a saved
|
|
307
|
+
connection (see "Connecting to Snowflake or an external DuckDB file" below) — pick
|
|
308
|
+
"Time series" there for the full drill-down/roll-up view (with metric, time-field, and
|
|
309
|
+
starting-grain pickers), or "Metric query" for the original metric+group-by mode. In
|
|
310
|
+
"Demo dataset" mode, an "Export demo dataset (.duckdb)" button downloads that same
|
|
311
|
+
data as a real file - the CLI equivalent of `lexis export-demo-dataset` above.
|
|
312
|
+
|
|
313
|
+
## Quickstart: Docker or Podman
|
|
314
|
+
|
|
315
|
+
Two images: `lexis-api` (FastAPI backend, migrations run automatically on
|
|
316
|
+
container start) and `lexis-web` (the built SPA served by nginx, which also
|
|
317
|
+
reverse-proxies `/api/*` to the backend - same same-origin-`/api` pattern the Vite
|
|
318
|
+
dev proxy uses, just in production).
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
docker compose up -d --build
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Open `http://localhost:8000` (the `web` container publishes nginx's port 8080 on
|
|
325
|
+
host `8000` - see `docker-compose.yml`). The SQLite database lives on a named
|
|
326
|
+
volume (`lexis-data`, mounted at `/data` in the API container), so it survives
|
|
327
|
+
`docker compose down`/`up` and container restarts - only `docker compose down -v`
|
|
328
|
+
removes it. Override `LEXIS_CORS_ORIGINS`/`LEXIS_MAX_DUCKDB_UPLOAD_MB`/etc.
|
|
329
|
+
(see `src/lexis_api/config.py`) via `environment:` in `docker-compose.yml` if
|
|
330
|
+
needed - `LEXIS_CORS_ORIGINS` must list the origin you open in the browser, so
|
|
331
|
+
change it too if you remap the published port; if you raise the upload cap, also
|
|
332
|
+
raise nginx's `client_max_body_size` in `docker/nginx.conf` to match.
|
|
333
|
+
|
|
334
|
+
nginx re-resolves the `api` service at runtime (a `resolver` generated from the
|
|
335
|
+
container's DNS config at start, see `docker/nginx-resolver.sh`), so recreating
|
|
336
|
+
just the API container - `compose up -d --force-recreate api`, which gives it a
|
|
337
|
+
new IP - no longer 502s the frontend until `web` is restarted too.
|
|
338
|
+
|
|
339
|
+
**Demo data + connections:** layer `docker-compose.demo.yml` on top to set
|
|
340
|
+
`LEXIS_DEV_SETUP_DEMO=1` — the API then writes the bundled demo datasets and
|
|
341
|
+
registers a `duckdb_file` connection for each (`tpcds-demo`, `retail-demo`) on
|
|
342
|
+
startup, so the MCP endpoint / Run tab work with no manual `curl`. The `.duckdb`
|
|
343
|
+
files go to `/data/demo` on the `lexis-data` volume (kept across restarts).
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
podman compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build
|
|
347
|
+
# docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Prebuilt images:** `docker-compose-dockerhub.yml` / `docker-compose-ghcr.yml`
|
|
351
|
+
run the published images instead of building locally — `.github/workflows/publish-images.yml`
|
|
352
|
+
builds once per `vX.Y.Z` tag and pushes the same image to both
|
|
353
|
+
`docker.io/puspendubanerjee/lexis-{api,web}` and `ghcr.io/puspendubanerjee/lexis-{api,web}`:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
podman compose -f docker-compose-dockerhub.yml up -d # Docker Hub
|
|
357
|
+
podman compose -f docker-compose-ghcr.yml up -d # GHCR
|
|
358
|
+
LEXIS_IMAGE_TAG=0.2.0 podman compose -f docker-compose-ghcr.yml up -d # pin a release
|
|
359
|
+
podman compose -f docker-compose-ghcr.yml -f docker-compose.demo.yml up -d # + demo data
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
To build the images without compose (e.g. for pushing to a registry):
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
./scripts/docker-build.sh # lexis-api + lexis-web, tag :latest
|
|
366
|
+
./scripts/docker-build.sh 0.3.0 # ... tagged :0.3.0
|
|
367
|
+
./scripts/docker-build.sh --type uber # the single-container lexis-uber image instead
|
|
368
|
+
./scripts/docker-build.sh --type all # all three
|
|
369
|
+
./scripts/docker-build.sh --help # full usage
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The **`uber`** build (`docker/uber.Dockerfile`, `docker-compose.uber.yml`) bundles
|
|
373
|
+
the SPA and the API in one image on one port — no nginx. Use the default split
|
|
374
|
+
build when you want to scale or deploy the UI and API separately.
|
|
375
|
+
|
|
376
|
+
Both containers currently run as root and there's no HTTPS/reverse-auth in front of
|
|
377
|
+
them - fine for local/trusted-network use, but harden before exposing publicly.
|
|
378
|
+
|
|
379
|
+
### Using Podman instead
|
|
380
|
+
|
|
381
|
+
The Dockerfiles pin fully-qualified base images (`docker.io/...`) so rootless
|
|
382
|
+
Podman resolves them without prompting, and `docker-compose.yml` is a plain
|
|
383
|
+
Compose file both engines read. Use **`podman compose`** (Podman 4.7+), which
|
|
384
|
+
shells out to the Compose CLI (`docker compose` / `docker-compose`) pointed at the
|
|
385
|
+
Podman socket - so healthchecks and `depends_on: condition: service_healthy`
|
|
386
|
+
behave exactly as with Docker. The older standalone `podman-compose` package
|
|
387
|
+
honours neither and is not recommended here. The API healthcheck runs a script
|
|
388
|
+
file (`docker/healthcheck.py`) rather than an inline `python -c "..."` because
|
|
389
|
+
Podman mangles multi-word exec-form healthcheck commands.
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
# one-time: start the rootless API socket the Compose provider talks to
|
|
393
|
+
systemctl --user enable --now podman.socket
|
|
394
|
+
|
|
395
|
+
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/podman/podman.sock"
|
|
396
|
+
podman compose up -d --build # same flags as `docker compose`
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Or build the images directly with Podman (no socket needed):
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
CONTAINER_ENGINE=podman ./scripts/docker-build.sh # also auto-detected if docker isn't on PATH
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
Notes for rootless Podman: the `lexis-data` named volume lives under
|
|
406
|
+
`~/.local/share/containers/storage/volumes/` (not a Docker volume); published
|
|
407
|
+
ports `8080`/`8000` are >1024 so no privileged-port config is needed; and the
|
|
408
|
+
in-container root user maps to your host UID, so the SQLite file on the volume is
|
|
409
|
+
owned by you.
|
|
410
|
+
|
|
411
|
+
## Connecting to Snowflake or an external DuckDB file
|
|
412
|
+
|
|
413
|
+
Beyond the demo dataset and one-off `.duckdb`/`.db` uploads, you can register a
|
|
414
|
+
named, reusable **connection** and point any model's "Run" at it instead. Two
|
|
415
|
+
types are supported: `duckdb_file` (a DuckDB database file already sitting on the
|
|
416
|
+
API server's filesystem) and `snowflake`.
|
|
417
|
+
|
|
418
|
+
Any authenticated user can view/test/run against any connection (same
|
|
419
|
+
workspace-wide visibility as models); creating, updating, or deleting one
|
|
420
|
+
requires the Editor or Admin role, and only the connection's owner (or an Admin)
|
|
421
|
+
can update/delete it. Requests are attributed via the `X-Account-Id` header, same as
|
|
422
|
+
everywhere else in the API (see Quickstart: Web UI above).
|
|
423
|
+
|
|
424
|
+
**Create a DuckDB-file connection:**
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
curl -X POST http://localhost:8000/api/connections \
|
|
428
|
+
-H "X-Account-Id: 2" -H "Content-Type: application/json" \
|
|
429
|
+
-d '{
|
|
430
|
+
"name": "local-warehouse",
|
|
431
|
+
"type": "duckdb_file",
|
|
432
|
+
"config": {"path": "/data/warehouse.duckdb"}
|
|
433
|
+
}'
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
`path` is resolved on the **API server** (or, in Docker, inside the
|
|
437
|
+
`lexis-api` container) - it's not a client-side file picker. If you're
|
|
438
|
+
running via `docker compose`, mount the directory containing the file into the
|
|
439
|
+
container (alongside the existing `lexis-data` volume in
|
|
440
|
+
`docker-compose.yml`) so the path is reachable there.
|
|
441
|
+
|
|
442
|
+
**Create a Snowflake connection:**
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
curl -X POST http://localhost:8000/api/connections \
|
|
446
|
+
-H "X-Account-Id: 2" -H "Content-Type: application/json" \
|
|
447
|
+
-d '{
|
|
448
|
+
"name": "prod-snowflake",
|
|
449
|
+
"type": "snowflake",
|
|
450
|
+
"config": {
|
|
451
|
+
"account": "xy12345.us-east-1",
|
|
452
|
+
"user": "LEXIS_SVC",
|
|
453
|
+
"password_env": "SNOWFLAKE_PASSWORD",
|
|
454
|
+
"warehouse": "COMPUTE_WH",
|
|
455
|
+
"database": "ANALYTICS",
|
|
456
|
+
"schema": "PUBLIC",
|
|
457
|
+
"role": "ANALYST"
|
|
458
|
+
}
|
|
459
|
+
}'
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`account`/`user`/`password_env` are required; `warehouse`/`database`/`schema`/
|
|
463
|
+
`role` are optional. Secrets are never stored in the database: `password_env` is
|
|
464
|
+
the *name* of an environment variable, and the API process reads the actual
|
|
465
|
+
password from its own environment (`export SNOWFLAKE_PASSWORD=...`, or an
|
|
466
|
+
`environment:` entry in `docker-compose.yml`) at connect time - so that variable
|
|
467
|
+
must be set wherever the API process runs, not passed in the request body.
|
|
468
|
+
|
|
469
|
+
**Test connectivity** (opens a real connection, no query run):
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
curl -X POST http://localhost:8000/api/connections/1/test -H "X-Account-Id: 2"
|
|
473
|
+
# {"ok": true, "detail": "connected successfully"}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
**Run a model's metric against a connection** (`connection_id` is the id from
|
|
477
|
+
the create response above; same `/run` endpoint used for demo/upload, with
|
|
478
|
+
`mode=connection`):
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
curl -X POST http://localhost:8000/api/models/1/run \
|
|
482
|
+
-H "X-Account-Id: 2" \
|
|
483
|
+
-F "mode=connection" -F "connection_id=1" \
|
|
484
|
+
-F "metric=total_sales" -F 'group_by_json=["item.i_category"]'
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The time-series endpoint (`/api/models/{id}/run/timeseries`) takes the same
|
|
488
|
+
`mode=connection`/`connection_id` fields alongside its usual `time_dataset`/
|
|
489
|
+
`time_field`/`grain`/`filter_grain`/`filter_value` form fields. For a
|
|
490
|
+
`duckdb_file` connection, every dataset referenced by the metric/group-by must
|
|
491
|
+
share one catalog name (the first `.`-segment of the dataset's `source` in the
|
|
492
|
+
Ossie model) - the file is attached under that name, mirroring how the demo/upload
|
|
493
|
+
modes work. Snowflake has no such restriction: `source` is used as-is, so it can
|
|
494
|
+
reference any `database.schema.table` the connection's role can see.
|
|
495
|
+
|
|
496
|
+
The web UI's **Connections** page (linked from the header) covers all of the above
|
|
497
|
+
graphically - create/edit/delete/test a connection, with the same RBAC - and the
|
|
498
|
+
model "Run" tab's "Saved connection" mode lets you pick one to run against.
|
|
499
|
+
|
|
500
|
+
## Using the live MCP server
|
|
501
|
+
|
|
502
|
+
The `--target mcp` transpile output above is schema-only — it describes the tools but
|
|
503
|
+
doesn't run anything. For an AI tool to actually call a metric and get real query
|
|
504
|
+
results back, Lexis can also serve a model as a **live** MCP server, one
|
|
505
|
+
`query_<metric>` tool per metric, resolved against the demo dataset, a local DuckDB
|
|
506
|
+
file, or Snowflake.
|
|
507
|
+
|
|
508
|
+
Each `query_<metric>` tool takes `group_by` (dimension refs, constrained to an enum)
|
|
509
|
+
**or** `time_grain` (`day`/`week`/`month`/`quarter`/`year`) to get a period-by-period
|
|
510
|
+
trend instead of a single total — e.g. "sales by week". `time_grain` buckets are ISO
|
|
511
|
+
8601 periods (weeks start Monday); a model whose calendar differs (e.g. a US retail
|
|
512
|
+
Sunday–Saturday week) says so in its `ai_context`, surfaced to the client as the MCP
|
|
513
|
+
server's `instructions` and in `list_metrics`. `time_field` picks the date axis when
|
|
514
|
+
a model has more than one.
|
|
515
|
+
|
|
516
|
+
There are three ways to wire a client up to it, depending on what you're doing:
|
|
517
|
+
|
|
518
|
+
| # | Approach | Reaches localhost? | Needs public exposure? |
|
|
519
|
+
|---|---|---|---|
|
|
520
|
+
| 1 | **Local (stdio)** — the client spawns `lexis mcp-serve` itself | n/a (same process tree) | No |
|
|
521
|
+
| 2 | **`mcp-remote` bridge** — the client spawns `mcp-remote`, which proxies to `lexis_api` over plain local HTTP | Yes, from the same machine | **No** |
|
|
522
|
+
| 3 | **Public tunnel** (cloudflared/ngrok) — for Claude's own *remote connector* UI, which calls from Anthropic's cloud, not your laptop | No — genuinely public | **Yes** |
|
|
523
|
+
|
|
524
|
+
Approaches 2 and 3 both talk to the same [remote HTTP endpoint](#the-remote-http-endpoint-approaches-2-and-3);
|
|
525
|
+
approach 2 is the better choice whenever you just want to exercise that endpoint
|
|
526
|
+
yourself, since it never leaves your machine.
|
|
527
|
+
|
|
528
|
+
### Approach 1: local (stdio) — e.g. Claude Desktop or any MCP client that launches a subprocess
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
pip install -e ".[mcp]"
|
|
532
|
+
lexis mcp-serve tests/fixtures/tpcds_semantic_model.yaml --demo
|
|
533
|
+
# larger bundled dataset (10,000 facts, 17 metrics):
|
|
534
|
+
# lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo
|
|
535
|
+
# or: --duckdb-file /path/to/warehouse.duckdb
|
|
536
|
+
# or: --snowflake-account ... --snowflake-user ... --snowflake-password-env ...
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
`--demo` serves the bundled dataset whose catalog matches the model
|
|
540
|
+
(`tpcds.*` → the TPC-DS fixture, `retail.*` → the retail analytics dataset); see
|
|
541
|
+
[The bundled demo datasets](#the-bundled-demo-datasets).
|
|
542
|
+
|
|
543
|
+
Point a client's config at it, e.g. Claude Desktop's `claude_desktop_config.json`:
|
|
544
|
+
|
|
545
|
+
```json
|
|
546
|
+
{
|
|
547
|
+
"mcpServers": {
|
|
548
|
+
"lexis": {
|
|
549
|
+
"command": "lexis",
|
|
550
|
+
"args": ["mcp-serve", "/path/to/model.yaml", "--demo"]
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
`--duckdb-file`/`--snowflake-*` requires every dataset the model's metrics touch to
|
|
557
|
+
be reachable the same way the corresponding **Connection** run mode already requires
|
|
558
|
+
(see "Connecting to Snowflake or an external DuckDB file" above) — a `--duckdb-file`
|
|
559
|
+
model's datasets must all share one catalog name. If a metric references a table your
|
|
560
|
+
dataset doesn't have (e.g. running `--demo` against a model with a `store` dataset,
|
|
561
|
+
which isn't in the bundled demo data), that one tool call fails with the underlying
|
|
562
|
+
DB error — other metrics keep working.
|
|
563
|
+
|
|
564
|
+
### The remote HTTP endpoint (approaches 2 and 3)
|
|
565
|
+
|
|
566
|
+
Mounted on the API, bound to an existing saved Connection:
|
|
567
|
+
|
|
568
|
+
```text
|
|
569
|
+
POST/GET/DELETE /api/models/{model_id}/mcp?connection_id=<id>
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Requires the same `X-Account-Id` header as the rest of the API, and a `connection_id`
|
|
573
|
+
for a Connection you've already created (see above) — the endpoint has no demo/upload
|
|
574
|
+
mode, since a remote MCP client can't provide a file per request. `connection_id` is
|
|
575
|
+
**not** the model's id, and there's no connection until you create one — a fresh
|
|
576
|
+
install has none, so calling this endpoint before creating a connection fails with
|
|
577
|
+
`{"detail":"connection not found"}`. If you just want to try it against the bundled
|
|
578
|
+
demo data:
|
|
579
|
+
|
|
580
|
+
```bash
|
|
581
|
+
lexis export-demo-dataset --out /tmp/tpcds-demo.duckdb --force
|
|
582
|
+
|
|
583
|
+
curl -X POST http://localhost:8000/api/connections \
|
|
584
|
+
-H "X-Account-Id: 2" -H "Content-Type: application/json" \
|
|
585
|
+
-d '{"name":"demo","type":"duckdb_file","config":{"path":"/tmp/tpcds-demo.duckdb"}}'
|
|
586
|
+
# -> note the "id" in the response, use it as connection_id below
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Then point any MCP client that supports a remote HTTP server at the model's `/mcp`
|
|
590
|
+
URL; it speaks the standard MCP Streamable HTTP transport, e.g.:
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
curl -X POST "http://localhost:8000/api/models/1/mcp?connection_id=<id-from-above>" \
|
|
594
|
+
-H "X-Account-Id: 2" -H "Content-Type: application/json" \
|
|
595
|
+
-H "Accept: application/json, text/event-stream" \
|
|
596
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
|
|
597
|
+
"params":{"protocolVersion":"2025-06-18","capabilities":{},
|
|
598
|
+
"clientInfo":{"name":"curl","version":"0"}}}'
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Each tool call opens the connection fresh (same per-request cost model the `/run`
|
|
602
|
+
endpoint already has) and returns the metric's real result rows, not just the schema.
|
|
603
|
+
|
|
604
|
+
#### A workspace-wide alternative: switch models/connections without reconnecting
|
|
605
|
+
|
|
606
|
+
The endpoint above fixes one model+connection in the URL - reasonable for a client
|
|
607
|
+
that only ever cares about one model, but it means picking a different model or
|
|
608
|
+
connection means reconnecting to a different URL. `POST/GET/DELETE /api/mcp` (no
|
|
609
|
+
`model_id`/`connection_id` in the URL at all) is the alternative: one connection,
|
|
610
|
+
four generic tools, `model_id`/`connection_id` supplied as **tool-call arguments**
|
|
611
|
+
instead:
|
|
612
|
+
|
|
613
|
+
- `list_models` - every model in the workspace (id, name, description)
|
|
614
|
+
- `list_connections` - every connection (id, name, type)
|
|
615
|
+
- `list_metrics(model_id)` - a model's metrics, each with its description and valid
|
|
616
|
+
`group_by` references, plus the model's `time_fields` / `time_grains` and any
|
|
617
|
+
model-level `instructions` (the same data the per-model endpoint bakes into each
|
|
618
|
+
`query_<metric>` tool's schema, just returned as data here instead)
|
|
619
|
+
- `query_metric(model_id, metric, connection_id, group_by? | time_grain? + time_field?)` - runs it
|
|
620
|
+
|
|
621
|
+
The trade-off: the per-model endpoint's one-governed-tool-per-metric design (a
|
|
622
|
+
distinct `query_<metric>` tool, `group_by` constrained to a real enum in the JSON
|
|
623
|
+
schema itself) becomes one generic `query_metric` tool instead, since the tool
|
|
624
|
+
schema can no longer depend on which `model_id` shows up in a given call - an
|
|
625
|
+
agent has to call `list_metrics` first to discover what's valid rather than having
|
|
626
|
+
it enforced by the schema. Point either the `mcp-remote` bridge (below) or a
|
|
627
|
+
tunnel at `http://localhost:8000/api/mcp` instead of the per-model URL to use it.
|
|
628
|
+
|
|
629
|
+
### Approach 2: `mcp-remote` as a local stdio bridge (no public exposure)
|
|
630
|
+
|
|
631
|
+
When you add a **custom (remote) connector** in Claude Desktop's Settings → Connectors
|
|
632
|
+
or claude.ai, Anthropic's cloud infrastructure — not your local Desktop app — is what
|
|
633
|
+
actually opens the HTTP connection to the URL you give it. `localhost` from their
|
|
634
|
+
servers' point of view means *their own server*, not your laptop, so that path
|
|
635
|
+
genuinely requires a publicly reachable URL (approach 3, below).
|
|
636
|
+
|
|
637
|
+
If you just want to exercise the remote HTTP endpoint above without any public
|
|
638
|
+
exposure, [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) is a small local
|
|
639
|
+
bridge: Claude spawns it as an ordinary local (stdio) subprocess — same mechanism as
|
|
640
|
+
approach 1 — and *it* makes the HTTP call to `lexis_api` from your own machine, where
|
|
641
|
+
`localhost` means exactly what you'd expect. No tunnel, no TLS, no public exposure:
|
|
642
|
+
|
|
643
|
+
```json
|
|
644
|
+
{
|
|
645
|
+
"mcpServers": {
|
|
646
|
+
"lexis-remote": {
|
|
647
|
+
"command": "npx",
|
|
648
|
+
"args": [
|
|
649
|
+
"mcp-remote",
|
|
650
|
+
"http://localhost:8000/api/models/1/mcp?connection_id=1",
|
|
651
|
+
"--allow-http",
|
|
652
|
+
"--header",
|
|
653
|
+
"X-Account-Id: 2"
|
|
654
|
+
]
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`--allow-http` is required since the endpoint here is plain HTTP (`mcp-remote` refuses
|
|
661
|
+
non-HTTPS URLs by default, for good reason on a real network — this one never leaves
|
|
662
|
+
your machine). `--header` supplies the same `X-Account-Id` auth the API needs everywhere
|
|
663
|
+
else. Verified working end-to-end: `initialize` → `notifications/initialized` →
|
|
664
|
+
`tools/call query_total_sales` all round-trip correctly through the bridge.
|
|
665
|
+
|
|
666
|
+
### Approach 3: exposing the endpoint over HTTPS (for Claude's own remote connector)
|
|
667
|
+
|
|
668
|
+
If you specifically want to use Claude's built-in remote-connector UI (rather than
|
|
669
|
+
`mcp-remote`), you do need a genuinely public HTTPS URL, for the reason above. For
|
|
670
|
+
local development, the quickest way to get one is a tunnel that terminates TLS for you
|
|
671
|
+
and forwards to your local port, with no cert management needed:
|
|
672
|
+
|
|
673
|
+
```bash
|
|
674
|
+
# install once (see cloudflare's docs for your OS if `brew`/`apt` aren't available)
|
|
675
|
+
brew install cloudflared # or: apt install cloudflared
|
|
676
|
+
|
|
677
|
+
# with lexis_api already running on :8000 - logging to .dev/ alongside
|
|
678
|
+
# scripts/dev.sh's own api.log/web.log, backgrounded so the shell stays free:
|
|
679
|
+
nohup cloudflared tunnel --url http://localhost:8000 > .dev/cloudflared.log 2>&1 &
|
|
680
|
+
sleep 5 && grep -o 'https://[a-zA-Z0-9.-]*trycloudflare\.com' .dev/cloudflared.log
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
That prints the random `https://<something>.trycloudflare.com` URL straight from the
|
|
684
|
+
log. Your full connector URL is then:
|
|
685
|
+
|
|
686
|
+
```text
|
|
687
|
+
https://<something>.trycloudflare.com/api/models/<model_id>/mcp?connection_id=<connection_id>
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
**ngrok** is an equivalent alternative if you'd rather use that:
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
ngrok http 8000
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
This gives a random `https://<random>.ngrok-free.app` URL, same trade-off as
|
|
697
|
+
`cloudflared`'s quick tunnel above — it changes every restart, so the connector URL
|
|
698
|
+
needs re-entering each time (and since Claude's custom connectors have no edit
|
|
699
|
+
option, that means delete-and-recreate, not just editing a field).
|
|
700
|
+
|
|
701
|
+
ngrok's free tier includes **one static/reserved domain per account**, which avoids
|
|
702
|
+
that entirely - the URL stays the same across restarts:
|
|
703
|
+
|
|
704
|
+
1. Claim it once: ngrok dashboard → Universal Edge → Domains → **+ New Domain**
|
|
705
|
+
(gives you something like `engaging-expose-annex.ngrok-free.dev`).
|
|
706
|
+
2. Bind it directly with `--domain` - no config file needed for a single stable tunnel:
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
ngrok http --domain=engaging-expose-annex.ngrok-free.dev 8000
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
Or, for a named, reusable config (`ngrok start <name>`), add it under `endpoints`/
|
|
713
|
+
`tunnels` in `~/.config/ngrok/ngrok.yml` with that domain + port, then
|
|
714
|
+
`ngrok start <name>`.
|
|
715
|
+
3. Your connector URL is now fixed:
|
|
716
|
+
`https://engaging-expose-annex.ngrok-free.dev/api/models/<model_id>/mcp?connection_id=<connection_id>`
|
|
717
|
+
(or `/api/mcp` for the [workspace-wide endpoint](#a-workspace-wide-alternative-switch-modelsconnections-without-reconnecting) —
|
|
718
|
+
never needs updating either way, since the domain doesn't change).
|
|
719
|
+
|
|
720
|
+
One gotcha specific to a *named* tunnel: ngrok only allows one running agent per
|
|
721
|
+
static domain at a time, across every machine on your account - if you get
|
|
722
|
+
`ERR_NGROK_334` ("endpoint is already online"), an earlier session (a different
|
|
723
|
+
terminal, a different device) still has it claimed; stop that one first, or check
|
|
724
|
+
the ngrok dashboard's Agents/Endpoints page to disconnect it remotely. [ngrok's
|
|
725
|
+
static domains blog post](https://ngrok.com/blog/free-static-domains-ngrok-users)
|
|
726
|
+
|
|
727
|
+
#### Tunneling the UI too, on the same port
|
|
728
|
+
|
|
729
|
+
ngrok's (and most tunnel providers') free tier gives you exactly one exposed
|
|
730
|
+
port/endpoint. If you also want to share the running web UI - not just the MCP
|
|
731
|
+
endpoint - through that same single tunnel, `lexis_api` can proxy its own port to
|
|
732
|
+
the Vite dev server, so one `uvicorn` port serves both:
|
|
733
|
+
|
|
734
|
+
```bash
|
|
735
|
+
# via scripts/dev.sh (derives the target from LEXIS_WEB_PORT automatically):
|
|
736
|
+
LEXIS_DEV_UI_PROXY=1 ./scripts/dev.sh start
|
|
737
|
+
|
|
738
|
+
# or standalone:
|
|
739
|
+
LEXIS_DEV_UI_PROXY_TARGET=http://localhost:5173 uvicorn lexis_api.main:app --port 8000
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
With that set, `:8000` serves `/api/...` as usual and forwards everything
|
|
743
|
+
else (including Vite's HMR WebSocket) to the dev server - so `ngrok http 8000` (or
|
|
744
|
+
the cloudflared tunnel above) now exposes the whole app, not just the API. It's
|
|
745
|
+
off unless that env var is set, and it's dev-only by design - production
|
|
746
|
+
(`docker-compose`) already has this same job done by `nginx` instead
|
|
747
|
+
(`docker/nginx.conf`), which this proxy doesn't replace or touch. This also
|
|
748
|
+
sidesteps Vite's own `Host`-header check (the "Blocked request... add to
|
|
749
|
+
`server.allowedHosts`" error) automatically, since the proxy always presents
|
|
750
|
+
itself to Vite as `localhost:5173` regardless of the tunnel's public hostname.
|
|
751
|
+
|
|
752
|
+
**Before you expose it**: `X-Account-Id` is a development-only auth stub in this codebase
|
|
753
|
+
(see `lexis_api/deps.py`) — anyone who reaches the tunnel URL can act as *any* user id
|
|
754
|
+
just by setting that header themselves, no password or token required. Only run the
|
|
755
|
+
tunnel while you're actively testing against your own machine, don't point it at a
|
|
756
|
+
database with real data, and kill it (`pkill cloudflared`, since the command above
|
|
757
|
+
backgrounds it) as soon as you're done — the hostname is random and will change on
|
|
758
|
+
every restart anyway, so there's no persistent URL to protect.
|
|
759
|
+
|
|
760
|
+
### Connecting Claude's remote connector to it (approach 3 only)
|
|
761
|
+
|
|
762
|
+
1. Claude Desktop: `Ctrl+,` (or the top-left menu → File → Settings) → **Connectors**
|
|
763
|
+
in the sidebar → **Add custom connector**.
|
|
764
|
+
2. Paste in the tunnel URL from above, including the `/api/models/<model_id>/mcp?connection_id=<connection_id>` path.
|
|
765
|
+
3. Look for a **Request headers** section in that same dialog and add `X-Account-Id` →
|
|
766
|
+
your user id (e.g. `2`). Claude stores it as the connector's credential and sends it
|
|
767
|
+
on every request — this is what satisfies the endpoint's auth requirement.
|
|
768
|
+
|
|
769
|
+
Request-header support for custom connectors is currently a **beta feature limited to
|
|
770
|
+
some organizations** — if you don't see that section, the dialog will only offer OAuth,
|
|
771
|
+
which this endpoint doesn't implement, and you won't be able to connect this
|
|
772
|
+
particular remote endpoint from Claude's UI without a small proxy in front of it that
|
|
773
|
+
injects the header for you. Approaches 1 and 2 above have no such limitation — neither
|
|
774
|
+
goes through this connector-UI auth path at all, since both configure the header (or
|
|
775
|
+
skip auth entirely) directly in `claude_desktop_config.json`.
|
|
776
|
+
|
|
777
|
+
### Sample questions to ask
|
|
778
|
+
|
|
779
|
+
Once connected (any of the three approaches), the model's `ai_context` synonyms let
|
|
780
|
+
you ask in plain language instead of naming the metric exactly.
|
|
781
|
+
|
|
782
|
+
Against the **`tpcds`** model (`lexis mcp-serve tests/fixtures/tpcds_semantic_model.yaml --demo`):
|
|
783
|
+
|
|
784
|
+
- "How's revenue breaking down by product category?"
|
|
785
|
+
- "What's our customer lifetime value?"
|
|
786
|
+
- "Break total sales down by brand."
|
|
787
|
+
- "Show me sales by year."
|
|
788
|
+
- "How productive are our stores?" (exercises `store_productivity`, a ratio metric)
|
|
789
|
+
|
|
790
|
+
Against the **`retail`** model (`lexis mcp-serve src/lexis_api/sample_data/retail_analytics_model.yaml --demo`),
|
|
791
|
+
which has enough data for the answers to be interesting:
|
|
792
|
+
|
|
793
|
+
- "What's total revenue and gross margin percent by product category?"
|
|
794
|
+
- "Show revenue by month — is there a Q4 bump?"
|
|
795
|
+
- "Which store format has the highest revenue per employee?"
|
|
796
|
+
- "Break average basket value down by customer loyalty tier."
|
|
797
|
+
- "How much are we losing to returns, and what's the top return reason?"
|
|
798
|
+
|
|
799
|
+
Two things worth knowing about the small **`tpcds`** `--demo` data specifically, so
|
|
800
|
+
unexpected answers don't read as bugs: it's only 2 items/2 customers, so per-item
|
|
801
|
+
attributes like brand and category are perfectly correlated (slicing by either gives
|
|
802
|
+
the same split); and a handful of `store_sales` rows deliberately reference an
|
|
803
|
+
item/customer id that doesn't exist, so an item- or customer-sliced metric will show a
|
|
804
|
+
smaller total than one sliced by date alone — that's correct `INNER JOIN` behavior on
|
|
805
|
+
intentionally incomplete sample data. The **`retail`** dataset has none of these
|
|
806
|
+
quirks: every fact row's foreign keys resolve, and the dimensions are fully populated.
|
|
807
|
+
|
|
808
|
+
## Running tests
|
|
809
|
+
|
|
810
|
+
```bash
|
|
811
|
+
pip install -e ".[dev]" # core library + CLI tests only
|
|
812
|
+
pytest tests --ignore=tests/api
|
|
813
|
+
|
|
814
|
+
pip install -e ".[dev,api]" # everything, including the API test suite
|
|
815
|
+
pytest
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
## Project structure
|
|
819
|
+
|
|
820
|
+
```text
|
|
821
|
+
src/lexis/ core library: Ossie parsing, join-graph resolution, transpilers, CLI
|
|
822
|
+
src/lexis/demo_data.py small fixed TPC-DS demo dataset (in-memory / exported .duckdb)
|
|
823
|
+
src/lexis/retail_demo_data.py generated 10,000-fact retail analytics demo dataset
|
|
824
|
+
src/lexis_api/ FastAPI backend (models, RBAC, transpile route, live query execution
|
|
825
|
+
against demo/upload DuckDB or a persisted connections.py connection)
|
|
826
|
+
src/lexis_api/sample_data/ bundled models seeded on first boot (tpcds + retail_analytics)
|
|
827
|
+
frontend/ Vite + React + TypeScript SPA
|
|
828
|
+
tests/ core library tests (fixtures under tests/fixtures/)
|
|
829
|
+
tests/api/ backend API tests
|
|
830
|
+
docs/architecture-plan.md architecture decisions and design rationale
|
|
831
|
+
docker/ Dockerfiles + nginx config (split: backend/frontend; single: uber)
|
|
832
|
+
scripts/docker-build.sh builds images directly with `docker build` (--type split|uber|all), no compose
|
|
833
|
+
docker-compose.uber.yml single-container variant (SPA + API in one image; + .uber.demo.yml overlay)
|
|
834
|
+
third_party/ossie/ git submodule: upstream Ossie spec/schema/converters docs/examples
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
## Keeping Ossie in sync
|
|
838
|
+
|
|
839
|
+
`src/lexis/_vendor/ossie/models.py` is a vendored (not pip-installed - `apache-ossie`
|
|
840
|
+
isn't on PyPI yet) copy of upstream's pydantic model classes, and `tests/fixtures/*.yaml`
|
|
841
|
+
are meant to conform to upstream's JSON Schema. Both are checked against the
|
|
842
|
+
`third_party/ossie` submodule by `tests/test_ossie_spec_conformance.py`, so a submodule bump
|
|
843
|
+
that changes either will fail loudly instead of silently drifting.
|
|
844
|
+
|
|
845
|
+
To pick up an upstream Ossie change:
|
|
846
|
+
|
|
847
|
+
```bash
|
|
848
|
+
git submodule update --remote third_party/ossie # bump the submodule to upstream's latest main
|
|
849
|
+
scripts/sync_ossie_vendor.sh # re-vendor models.py + refresh NOTICE.md's commit pin
|
|
850
|
+
pytest tests/test_ossie_spec_conformance.py tests/test_parser.py tests/test_resolved_model.py
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
`scripts/sync_ossie_vendor.sh` only overwrites `models.py` verbatim; `__init__.py` is
|
|
854
|
+
hand-adapted (relative import, own docstring) and the script just warns if a new
|
|
855
|
+
upstream class/name isn't re-exported yet, so it needs a manual one-line addition in
|
|
856
|
+
that case.
|
|
857
|
+
|
|
858
|
+
`third_party/ossie` is upstream's repo, not ours - never edit files inside it directly,
|
|
859
|
+
and never commit local changes to it (we have no push access, and a submodule pointer
|
|
860
|
+
referencing a commit we made locally but never pushed would break for everyone else
|
|
861
|
+
who clones this repo). Its `.gitmodules` entry sets `ignore = dirty`, so `git status`/
|
|
862
|
+
`git diff` won't even show local edits inside it; the only supported way to move it
|
|
863
|
+
forward is `git submodule update --remote third_party/ossie` followed by
|
|
864
|
+
`scripts/sync_ossie_vendor.sh`.
|
|
865
|
+
|
|
866
|
+
This is also enforced by two automated checks, both running
|
|
867
|
+
`scripts/check_ossie_submodule_pin.sh` (fails if `third_party/ossie` is pinned to a commit
|
|
868
|
+
that isn't reachable from any of its remote branches - i.e. a local-only commit made by
|
|
869
|
+
accidentally `cd`-ing into the submodule and committing there):
|
|
870
|
+
|
|
871
|
+
- **CI** (`.github/workflows/check-ossie-submodule.yml`) runs it on every push/PR - the
|
|
872
|
+
real backstop, since it can't be skipped.
|
|
873
|
+
- **A local pre-commit hook** (`.githooks/pre-commit`) runs it before any commit that
|
|
874
|
+
touches the submodule pin, so you find out before pushing rather than after CI fails.
|
|
875
|
+
Opt in once per clone (git doesn't version `.git/hooks`, so this isn't automatic):
|
|
876
|
+
|
|
877
|
+
```bash
|
|
878
|
+
git config core.hooksPath .githooks
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
## License
|
|
882
|
+
|
|
883
|
+
Apache 2.0 - see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|