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 +4 -0
- mudm_mcp/catalog_client.py +236 -0
- mudm_mcp/facets.py +68 -0
- mudm_mcp/geometry.py +56 -0
- mudm_mcp/sandbox.py +147 -0
- mudm_mcp/server.py +234 -0
- mudm_mcp/spatial.py +164 -0
- mudm_mcp/tools.py +541 -0
- mudm_mcp/units.py +46 -0
- mudm_mcp-0.1.0.dist-info/METADATA +147 -0
- mudm_mcp-0.1.0.dist-info/RECORD +15 -0
- mudm_mcp-0.1.0.dist-info/WHEEL +5 -0
- mudm_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- mudm_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- mudm_mcp-0.1.0.dist-info/top_level.txt +1 -0
mudm_mcp/__init__.py
ADDED
|
@@ -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
|