mudm-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mudm_mcp/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """muDM MCP — a local MCP server (client of the static muDM catalog) for agent/LLM discovery,
2
+ access, and local analysis of muDM spatial datasets."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,236 @@
1
+ """Read the static muDM catalog: a CLIENT of `pyramids.json` (mudm.PyramidJSON) + per-dataset
2
+ `tilejson.json` (mudm.TileModel) + derived `croissant.json`. No backend — fetches static files over
3
+ http(s) (cached to a local dir) or reads a local build dir (`file://` / plain path) for dev/tests.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import difflib
8
+ import json
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+ from urllib.parse import urlparse
13
+
14
+ DEFAULT_JSON_TTL_S = 24 * 3600.0
15
+
16
+
17
+ def default_cache_dir(host: str) -> Path:
18
+ return Path.home() / ".cache" / "mudm-mcp" / (host or "local")
19
+
20
+
21
+ class DatasetNotFound(KeyError):
22
+ """Unknown dataset id; ``suggestions`` holds up to 5 close catalog ids."""
23
+
24
+ def __init__(self, dataset_id: str, known_ids: list[str]):
25
+ super().__init__(f"dataset {dataset_id!r} not found in pyramids.json")
26
+ self.dataset_id = dataset_id
27
+ self.suggestions = difflib.get_close_matches(dataset_id, known_ids, n=5, cutoff=0.5)
28
+
29
+
30
+ def _zoom_of(part: str) -> int:
31
+ """'zoom=9/cells_part_000.mu.parquet' -> 9."""
32
+ return int(part.split("/", 1)[0].split("=", 1)[1])
33
+
34
+
35
+ class CatalogClient:
36
+ """Resolve + cache the catalog artifacts. ``base_url`` may be an http(s) origin (default
37
+ ``https://ngrf.mudm.io``) or a local directory (a `build_site --out` dir, as `file://…` or a path).
38
+
39
+ Cached catalog JSON is refetched once it is older than ``json_ttl_s`` (default 24 h), so a long-lived
40
+ install follows catalog updates; if the refetch fails (offline) the stale copy is served."""
41
+
42
+ def __init__(self, base_url: str, cache_dir: str | Path | None = None,
43
+ json_ttl_s: float = DEFAULT_JSON_TTL_S):
44
+ self.base_url = str(base_url).rstrip("/")
45
+ self.json_ttl_s = float(json_ttl_s)
46
+ parsed = urlparse(self.base_url)
47
+ self.is_http = parsed.scheme in ("http", "https")
48
+ if self.is_http:
49
+ self.cache_dir = Path(cache_dir) if cache_dir else default_cache_dir(parsed.netloc)
50
+ self._local_root: Path | None = None
51
+ else: # file:// or a plain local path
52
+ self._local_root = Path(parsed.path if parsed.scheme == "file" else self.base_url)
53
+ self.cache_dir = Path(cache_dir) if cache_dir else self._local_root
54
+
55
+ # --- paths ---
56
+ @staticmethod
57
+ def _rel(parts) -> str:
58
+ return "/".join(str(p).strip("/") for p in parts if str(p).strip("/"))
59
+
60
+ def url(self, *parts: str) -> str:
61
+ """Absolute URL (http origin) or local path (local origin) of a site-relative path."""
62
+ rel = self._rel(parts)
63
+ return f"{self.base_url}/{rel}" if self.is_http else str(self._local_root / rel)
64
+
65
+ # --- fetch / cache ---
66
+ def fetch(self, *parts: str) -> bytes:
67
+ """Bytes of a catalog-relative path (e.g. ``fetch("datasets", id, "tilejson.json")``)."""
68
+ rel = self._rel(parts)
69
+ if not self.is_http:
70
+ return (self._local_root / rel).read_bytes()
71
+ cached = self.cache_dir / rel
72
+ have = cached.is_file()
73
+ if have and (time.time() - cached.stat().st_mtime) < self.json_ttl_s:
74
+ return cached.read_bytes()
75
+ import httpx # lazy: file:// dev/tests need no network/httpx
76
+ try:
77
+ resp = httpx.get(f"{self.base_url}/{rel}", follow_redirects=True, timeout=60.0)
78
+ resp.raise_for_status()
79
+ except httpx.HTTPError:
80
+ if have: # expired but unreachable: a stale catalog beats no catalog
81
+ return cached.read_bytes()
82
+ raise
83
+ cached.parent.mkdir(parents=True, exist_ok=True)
84
+ tmp = cached.with_name(cached.name + ".part")
85
+ tmp.write_bytes(resp.content)
86
+ os.replace(tmp, cached)
87
+ return resp.content
88
+
89
+ def fetch_json(self, *parts: str) -> dict:
90
+ return json.loads(self.fetch(*parts))
91
+
92
+ def _try_fetch_json(self, *parts: str) -> dict | None:
93
+ """``fetch_json`` that returns None when the file does not exist (local miss / HTTP 4xx)."""
94
+ try:
95
+ return self.fetch_json(*parts)
96
+ except FileNotFoundError:
97
+ return None
98
+ except Exception as exc: # httpx.HTTPStatusError without importing httpx eagerly
99
+ status = getattr(getattr(exc, "response", None), "status_code", None)
100
+ if status is not None and 400 <= status < 500: # S3/CloudFront answer 403 for a missing key
101
+ return None
102
+ raise
103
+
104
+ def download_file(self, rel_parts, out_path) -> int:
105
+ """Stream a (possibly large) leaf file to ``out_path`` (http GET or local copy); returns bytes
106
+ written. Atomic: the data goes to ``<out_path>.part`` and is renamed only when complete, so an
107
+ interrupted download never leaves a truncated file under the final name. Unlike ``fetch`` this
108
+ does not hold the body in memory or use the JSON cache (parquet leaves are served uncompressed)."""
109
+ rel = self._rel(rel_parts)
110
+ out = Path(out_path)
111
+ out.parent.mkdir(parents=True, exist_ok=True)
112
+ tmp = out.with_name(out.name + ".part")
113
+ try:
114
+ if not self.is_http:
115
+ with open(self._local_root / rel, "rb") as src, open(tmp, "wb") as fh:
116
+ while chunk := src.read(1 << 20):
117
+ fh.write(chunk)
118
+ else:
119
+ import httpx # lazy
120
+ with httpx.stream("GET", f"{self.base_url}/{rel}", follow_redirects=True,
121
+ timeout=120.0) as resp:
122
+ resp.raise_for_status()
123
+ with open(tmp, "wb") as fh:
124
+ for chunk in resp.iter_bytes():
125
+ fh.write(chunk)
126
+ size = tmp.stat().st_size
127
+ os.replace(tmp, out)
128
+ except BaseException:
129
+ tmp.unlink(missing_ok=True)
130
+ raise
131
+ return size
132
+
133
+ # --- sizes ---
134
+ def part_size(self, base: str, part: str) -> int | None:
135
+ """Size in bytes of one leaf (local ``stat``; HTTP ``HEAD``, else a 1-byte ranged GET). None if
136
+ it cannot be determined."""
137
+ rel = self._rel([base, part])
138
+ if not self.is_http:
139
+ p = self._local_root / rel
140
+ return p.stat().st_size if p.is_file() else None
141
+ import httpx # lazy
142
+ url = f"{self.base_url}/{rel}"
143
+ try:
144
+ r = httpx.head(url, follow_redirects=True, timeout=30.0)
145
+ if r.status_code == 200 and "content-length" in r.headers:
146
+ return int(r.headers["content-length"])
147
+ r = httpx.get(url, headers={"Range": "bytes=0-0"}, follow_redirects=True, timeout=30.0)
148
+ total = r.headers.get("content-range", "").rsplit("/", 1)[-1]
149
+ return int(total) if total.isdigit() else None
150
+ except httpx.HTTPError:
151
+ return None
152
+
153
+ def estimate_bytes(self, base: str, parts: list[str], sample: int = 4) -> int | None:
154
+ """Total size of ``parts``: exact when there are at most ``sample`` of them, else the mean of
155
+ ``sample`` evenly spaced parts times the count. None if no size could be read."""
156
+ if not parts:
157
+ return 0
158
+ if len(parts) <= sample:
159
+ sizes = [self.part_size(base, p) for p in parts]
160
+ return None if any(s is None for s in sizes) else sum(sizes)
161
+ step = len(parts) / sample
162
+ sizes = [s for s in (self.part_size(base, parts[int(i * step)]) for i in range(sample)) if s is not None]
163
+ return round(sum(sizes) / len(sizes) * len(parts)) if sizes else None
164
+
165
+ # --- catalog accessors ---
166
+ def pyramids(self) -> dict:
167
+ """The unified 2D+3D catalog (mudm.PyramidJSON)."""
168
+ return self.fetch_json("pyramids.json")
169
+
170
+ def entries(self) -> list[dict]:
171
+ return self.pyramids().get("pyramids", [])
172
+
173
+ def entry(self, dataset_id: str) -> dict:
174
+ """The catalog entry (PyramidEntry: id/label/kind + OPEN facet metadata) for one dataset."""
175
+ entries = self.entries()
176
+ for e in entries:
177
+ if e.get("id") == dataset_id:
178
+ return e
179
+ raise DatasetNotFound(dataset_id, [str(e.get("id")) for e in entries])
180
+
181
+ def descriptor(self, dataset_id: str) -> dict:
182
+ """The per-dataset muDM TileModel descriptor (assets[] + TileLayer schema + multiscale coords)."""
183
+ return self.fetch_json("datasets", dataset_id, "tilejson.json")
184
+
185
+ def croissant(self, dataset_id: str) -> dict:
186
+ """The derived Croissant JSON-LD (schema.org/Dataset + cr:)."""
187
+ return self.fetch_json("datasets", dataset_id, "croissant.json")
188
+
189
+ def viewer_descriptor(self, dataset_id: str) -> tuple[str, dict] | None:
190
+ """The VIEWER's descriptor ``(<site-relative dir>, tilejson)`` that sits next to the tiles
191
+ (``tiles/<id>/`` for 3D, ``tiles2d/<id>/`` for 2D). Its asset hrefs are relative to that dir. Used
192
+ as a fallback when the agent descriptor (``datasets/<id>/tilejson.json``) omits an asset."""
193
+ entry = self.entry(dataset_id)
194
+ feats = (entry.get("features") or "").strip("/")
195
+ dirs = [feats.rsplit("/", 1)[0]] if "/" in feats else []
196
+ dirs += [d for d in (f"tiles/{dataset_id}", f"tiles2d/{dataset_id}") if d not in dirs]
197
+ for d in dirs:
198
+ doc = self._try_fetch_json(d, "tilejson.json")
199
+ if doc is not None:
200
+ return d, doc
201
+ return None
202
+
203
+ @staticmethod
204
+ def _asset(descriptor: dict, role: str) -> dict | None:
205
+ return next((a for a in descriptor.get("assets") or [] if a.get("role") == role), None)
206
+
207
+ def features(self, dataset_id: str) -> dict | None:
208
+ """The download index of a dataset's ``features.parquet``:
209
+ ``{base, parts, zooms, full_res_zoom, source}`` — ``base`` is the site-relative directory holding
210
+ the ``zoom=<z>/…`` leaves, ``source`` is ``"agent"`` or ``"viewer"`` (which descriptor carried it).
211
+ None when neither descriptor lists parquet parts."""
212
+ entry = self.entry(dataset_id)
213
+ entry_base = (entry.get("features") or "").strip("/")
214
+ asset = self._asset(self.descriptor(dataset_id), "features")
215
+ source, viewer_dir = "agent", None
216
+ if not (asset and asset.get("parts")):
217
+ found = self.viewer_descriptor(dataset_id)
218
+ asset = self._asset(found[1], "features") if found else None
219
+ if not (asset and asset.get("parts")):
220
+ return None
221
+ source, viewer_dir = "viewer", found[0]
222
+ href = str(asset.get("href") or "")
223
+ if entry_base:
224
+ base = entry_base
225
+ elif viewer_dir is not None:
226
+ base = self._rel([viewer_dir, href or "features.parquet"])
227
+ elif href.startswith(self.base_url + "/"):
228
+ base = href[len(self.base_url) + 1:].strip("/")
229
+ else:
230
+ raise ValueError(f"{dataset_id}: cannot locate features.parquet (href {asset.get('href')!r} is "
231
+ f"outside {self.base_url})")
232
+ parts = list(asset["parts"])
233
+ zooms = sorted(int(z) for z in (asset.get("zooms") or {_zoom_of(p) for p in parts}))
234
+ full = asset.get("full_res_zoom")
235
+ return {"base": base, "parts": parts, "zooms": zooms,
236
+ "full_res_zoom": int(full) if full is not None else zooms[-1], "source": source}
mudm_mcp/facets.py ADDED
@@ -0,0 +1,68 @@
1
+ """query_facets — SQL over a dataset's facet store (a per-cell / per-neuron attribute table published
2
+ as ONE parquet file next to the tiles), read in place over HTTP range requests: no download.
3
+
4
+ Facet stores by assay: Xenium ``expression`` (long: cell_id, gene, count) · CODEX / CosMx / MERSCOPE /
5
+ MIBI ``markers`` (wide: one column per marker or gene) · connectomes and reference anatomy ``properties``
6
+ (wide: one row per neuron / structure with cell_type, synapse counts, …). Visium has none.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .catalog_client import CatalogClient
11
+ from .sandbox import open_sandbox, run_select
12
+ from .tools import facet_assets
13
+
14
+ MAX_SCHEMA_COLUMNS = 60
15
+
16
+
17
+ def _names(cols: list[str], cap: int = MAX_SCHEMA_COLUMNS) -> str:
18
+ more = f", … (+{len(cols) - cap} more)" if len(cols) > cap else ""
19
+ return ", ".join(cols[:cap]) + more
20
+
21
+
22
+ def query_facets(client: CatalogClient, dataset_id: str, sql: str, facet: str | None = None,
23
+ max_rows: int = 200) -> dict:
24
+ """Run ONE read-only SELECT against a dataset's facet store, exposed as the view ``facets`` (the
25
+ store's own name, e.g. ``expression`` or ``properties``, works as an alias).
26
+
27
+ ``facet`` picks the store by name when a dataset has several. Returns the bounded result of
28
+ ``run_select`` plus ``facet``, ``source`` (the file's URL) and ``schema`` — the file's REAL columns,
29
+ which can differ from what the descriptor advertises (``missing_columns`` lists advertised-but-absent
30
+ ones)."""
31
+ import duckdb # lazy
32
+
33
+ client.entry(dataset_id) # unknown id -> DatasetNotFound with suggestions
34
+ stores = facet_assets(client, dataset_id)
35
+ if not stores:
36
+ raise ValueError(f"{dataset_id} has no facet store (Visium datasets have none). "
37
+ "describe_dataset lists a dataset's facets; download + query reads the features.")
38
+ names = [s["name"] for s in stores]
39
+ if facet:
40
+ store = next((s for s in stores if s["name"] == facet), None)
41
+ if store is None:
42
+ raise ValueError(f"{dataset_id} has no facet store named {facet!r}; available: {', '.join(names)}")
43
+ elif len(stores) > 1:
44
+ raise ValueError(f"{dataset_id} has several facet stores; pass facet= one of: {', '.join(names)}")
45
+ else:
46
+ store = stores[0]
47
+
48
+ con = open_sandbox("facets", store["url"])
49
+ try:
50
+ # Agents often write FROM <store name> (e.g. FROM expression) because that is the name
51
+ # describe_dataset shows: accept it as an alias of the `facets` view instead of failing.
52
+ if store["name"].isidentifier() and store["name"].lower() != "facets":
53
+ con.execute(f'CREATE VIEW "{store["name"]}" AS SELECT * FROM facets')
54
+ schema = [{"name": r[0], "type": r[1]} for r in con.execute("DESCRIBE facets").fetchall()]
55
+ cols = [c["name"] for c in schema]
56
+ try:
57
+ result = run_select(con, sql, max_rows=max_rows)
58
+ except (duckdb.BinderException, duckdb.CatalogException) as exc:
59
+ first = str(exc).split("\n", 1)[0]
60
+ raise ValueError(f"{first} The view is named `facets`. Available columns: {_names(cols)}") from exc
61
+ finally:
62
+ con.close()
63
+ out = {**result, "facet": store["name"], "layout": store["layout"], "source": store["url"],
64
+ "n_columns": len(schema), "schema": schema[:MAX_SCHEMA_COLUMNS]}
65
+ missing = [c for c in store["columns"] if c not in cols]
66
+ if missing:
67
+ out["missing_columns"] = missing[:MAX_SCHEMA_COLUMNS]
68
+ return out
mudm_mcp/geometry.py ADDED
@@ -0,0 +1,56 @@
1
+ """Decode muDM binary tile geometry (the `features.parquet` `positions`/`indices`/`ring_lengths` columns).
2
+
3
+ This is muDM's OWN binary layout — **NOT GeoParquet/WKB**, so `ST_*`/`shapely.wkb` do not apply. Verified
4
+ against real `features.parquet` leaves (2026-06-23):
5
+ - `positions` = packed little-endian float32, vertices interleaved `[x,y(,z)…]` (image-pixel µm, NOT lon/lat).
6
+ - `indices` = packed little-endian uint32 triangle indices — present only for mesh types; EMPTY for
7
+ point/line/polygon.
8
+ - `ring_lengths` = per-ring vertex counts (polygons/lines); EMPTY for points.
9
+ - point `geom_type=1`: 8 bytes → 1 vertex, no rings. polygon `geom_type=3`: packed verts + ring_lengths.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+ # OGC-style geometry-type codes used by muDM tile geometry.
16
+ POINT, LINESTRING, POLYGON = 1, 2, 3
17
+
18
+
19
+ def decode_geometry(
20
+ positions: bytes,
21
+ indices: bytes,
22
+ ring_lengths,
23
+ geom_type: int,
24
+ dims: int | None = None,
25
+ ) -> dict:
26
+ """Decode one muDM tile-geometry row into NumPy arrays (zero-copy via ``frombuffer``).
27
+
28
+ Args:
29
+ positions: packed little-endian float32 vertex coordinates (`[x,y(,z)…]`).
30
+ indices: packed little-endian uint32 triangle indices (mesh types; ``b""`` otherwise).
31
+ ring_lengths: per-ring vertex counts (polygons/lines); empty/None for points.
32
+ geom_type: OGC-style code (1=Point, 2=LineString, 3=Polygon; mesh types carry ``indices``).
33
+ dims: 2 or 3. If None, inferred from ``ring_lengths`` (or a single-vertex point), defaulting to 2.
34
+
35
+ Returns:
36
+ ``{geom_type, dims, coords: float32[N,dims], rings?: [float32[ni,dims]], triangles?: uint32[T,3]}``.
37
+ """
38
+ floats = np.frombuffer(positions, dtype="<f4")
39
+ rings = [int(n) for n in (ring_lengths or [])]
40
+ if dims is None:
41
+ total = sum(rings)
42
+ if total:
43
+ dims = len(floats) // total
44
+ elif geom_type == POINT and len(floats):
45
+ dims = len(floats) # a single vertex
46
+ else:
47
+ dims = 2
48
+ dims = int(dims) or 2
49
+ coords = floats.reshape(-1, dims)
50
+ out: dict = {"geom_type": int(geom_type), "dims": dims, "coords": coords}
51
+ if rings:
52
+ bounds = np.cumsum([0, *rings])
53
+ out["rings"] = [coords[bounds[i]:bounds[i + 1]] for i in range(len(rings))]
54
+ if indices:
55
+ out["triangles"] = np.frombuffer(indices, dtype="<u4").reshape(-1, 3)
56
+ return out
mudm_mcp/sandbox.py ADDED
@@ -0,0 +1,147 @@
1
+ """A locked-down DuckDB connection for agent-written SQL, and a bounded SELECT runner.
2
+
3
+ The SQL comes from an LLM, so the connection may read exactly ONE source (a parquet file, a directory of
4
+ parquet leaves, or one https URL) and nothing else: no other local files, no other URLs, no writes
5
+ (COPY/ATTACH/EXPORT), no extension installs, and the configuration is locked so none of it can be
6
+ switched back on. Results are capped in rows and in characters so they fit an MCP host's output budget.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ MAX_ROWS_LIMIT = 5000
15
+ MAX_CELL_CHARS = 500
16
+ MAX_COLUMNS = 500
17
+ MAX_RESULT_CHARS = 15_000 # serialized rows budget (the whole tool response must stay under 20k)
18
+
19
+
20
+ def _q(text: str) -> str:
21
+ return str(text).replace("'", "''")
22
+
23
+
24
+ def _ca_bundle() -> str | None:
25
+ try:
26
+ import certifi
27
+ return certifi.where()
28
+ except Exception:
29
+ return None
30
+
31
+
32
+ def open_sandbox(view: str, source: str, *, is_dir: bool = False, hive: bool | None = None):
33
+ """A DuckDB connection whose only readable data is ``source``, exposed as the view ``view``.
34
+
35
+ ``source``: a local parquet file, a local directory (``is_dir=True`` → every ``*.parquet`` below
36
+ it), or an http(s) URL of one parquet file. ``hive``: force ``hive_partitioning`` on/off (default:
37
+ DuckDB's auto-detection). The caller closes the connection."""
38
+ import duckdb # lazy
39
+
40
+ source = str(source)
41
+ remote = source.startswith(("http://", "https://"))
42
+ con = duckdb.connect()
43
+ try:
44
+ if remote:
45
+ con.execute("INSTALL httpfs") # before the lockdown: installing needs file + network access
46
+ con.execute("LOAD httpfs")
47
+ con.execute("SET allow_persistent_secrets=false") # else CREATE VIEW probes ~/.duckdb after lockdown
48
+ if is_dir:
49
+ root = str(Path(source).resolve())
50
+ con.execute(f"SET allowed_directories=['{_q(root)}/']")
51
+ scan_src = f"{root}/**/*.parquet"
52
+ else:
53
+ scan_src = source if remote else str(Path(source).resolve())
54
+ allowed = [scan_src]
55
+ if remote:
56
+ # DuckDB >= 2.0 reads a CA bundle from disk for TLS, which the lockdown would forbid:
57
+ # allow exactly one bundle (certifi's public root list) and point httpfs at it.
58
+ bundle = _ca_bundle()
59
+ if bundle:
60
+ allowed.append(bundle)
61
+ try:
62
+ con.execute(f"SET ca_cert_file='{_q(bundle)}'")
63
+ except duckdb.Error:
64
+ pass # older httpfs without the setting needs no bundle
65
+ con.execute("SET allowed_paths=[" + ", ".join(f"'{_q(a)}'" for a in allowed) + "]")
66
+ con.execute("SET enable_external_access=false")
67
+ opts = "" if hive is None else f", hive_partitioning={'true' if hive else 'false'}"
68
+ con.execute(f'CREATE VIEW "{view}" AS SELECT * FROM read_parquet(\'{_q(scan_src)}\'{opts})')
69
+ con.execute("SET lock_configuration=true")
70
+ except BaseException:
71
+ con.close()
72
+ raise
73
+ return con
74
+
75
+
76
+ def jsonable(v: Any, cap: int = MAX_CELL_CHARS) -> Any:
77
+ """A JSON-safe, size-bounded rendering of one DuckDB value: strings are cut at ``cap`` characters,
78
+ nested values (maps, lists, structs) at ``4 * cap`` characters of their JSON."""
79
+ if v is None or isinstance(v, (bool, int)):
80
+ return v
81
+ if isinstance(v, float):
82
+ return v if math.isfinite(v) else None
83
+ if isinstance(v, (bytes, bytearray, memoryview)):
84
+ return f"<binary {len(v)}B>" # muDM geometry blobs: decode via sample_patches / count_features
85
+ if isinstance(v, str):
86
+ return v if len(v) <= cap else v[:cap] + "..."
87
+ if isinstance(v, (dict, list, tuple)):
88
+ import json
89
+ items = ({str(k): jsonable(x, cap) for k, x in list(v.items())[:100]} if isinstance(v, dict)
90
+ else [jsonable(x, cap) for x in v[:100]])
91
+ text = json.dumps(items)
92
+ return items if len(text) <= 4 * cap else text[:4 * cap] + "..."
93
+ return jsonable(str(v), cap) # dates, decimals, UUIDs, …
94
+
95
+
96
+ def _single_select(con, sql: str) -> str:
97
+ import duckdb # lazy
98
+
99
+ text = str(sql or "").strip()
100
+ while text.endswith(";"):
101
+ text = text[:-1].rstrip()
102
+ if not text:
103
+ raise ValueError("Empty SQL. Only a single SELECT statement is allowed.")
104
+ try:
105
+ statements = con.extract_statements(text)
106
+ except duckdb.Error as exc:
107
+ raise ValueError(f"SQL syntax error: {exc}") from exc
108
+ if len(statements) != 1 or statements[0].type != duckdb.StatementType.SELECT:
109
+ raise ValueError("Only a single SELECT statement is allowed.")
110
+ return text
111
+
112
+
113
+ def run_select(con, sql: str, max_rows: int = 200) -> dict:
114
+ """Run one SELECT and return ``{"columns", "n_rows", "rows", "truncated"[, "note"]}``.
115
+
116
+ At most ``max_rows`` rows (clamped to 1..5000) and about 15,000 characters of rows come back;
117
+ ``truncated`` says whether more rows existed. A trailing ``;`` is accepted; anything that is not
118
+ exactly one SELECT raises ``ValueError``."""
119
+ import json
120
+
121
+ text = _single_select(con, sql)
122
+ max_rows = max(1, min(int(max_rows), MAX_ROWS_LIMIT))
123
+ rel = con.execute(text)
124
+ cols = [d[0] for d in rel.description]
125
+ if len(cols) > MAX_COLUMNS:
126
+ raise ValueError(f"The result has {len(cols)} columns; select at most {MAX_COLUMNS} "
127
+ "(name the columns you need instead of SELECT *).")
128
+ # The column header and every cell share one character budget, so a wide result gets shorter cells
129
+ # and fewer rows rather than an oversized response.
130
+ budget = MAX_RESULT_CHARS - len(json.dumps(cols))
131
+ cap = max(16, min(MAX_CELL_CHARS, budget // (4 * max(1, len(cols)))))
132
+ fetched = rel.fetchmany(max_rows + 1)
133
+ rows, used, note = [], 0, None
134
+ for raw in fetched[:max_rows]:
135
+ row = [jsonable(v, cap) for v in raw]
136
+ used += len(json.dumps(row)) # ASCII-escaped: the worst case
137
+ if rows and used > budget:
138
+ note = (f"Stopped after {len(rows)} rows to keep the response small; select fewer columns, "
139
+ "aggregate, or add LIMIT/OFFSET.")
140
+ break
141
+ rows.append(row)
142
+ truncated = len(fetched) > max_rows or note is not None
143
+ out = {"columns": cols, "n_rows": len(rows), "rows": rows, "truncated": truncated}
144
+ if truncated:
145
+ out["note"] = note or (f"More than {max_rows} rows matched; only the first {max_rows} are shown. "
146
+ "Aggregate, filter, or raise max_rows (up to 5000).")
147
+ return out