Skip to content

Commit eb331eb

Browse files
committed
experimental image: raster + zarr extensions on a separate tag and endpoint
Adds a second image (Dockerfile.experimental -> :experimental) layered on the default one, carrying the duckdb raster and zarr community extensions, plus a testing-only endpoint at experimental-duckdb-mcp.nrp-nautilus.io. raster gives RT_ReadCells/RT_Read (GDAL) so a small GeoTIFF can be joined to catalog GeoParquet directly, without a hex ingest first; zarr gives read_zarr. Both stay off the default endpoint: raster pulls GDAL into the query path, and RT_ReadCells materializes one row per pixel, which is only safe for small rasters. One codebase serves both tags. server.py LOADs whatever EXTRA_DUCKDB_EXTENSIONS names per connection (unset on the default image, so behaviour is unchanged) and injects experimental-extensions.md into the query tool description only where the extras are actually loaded. Extension names are restricted to identifier characters since LOAD takes no parameters. The guidance carries the two traps found while testing: RT_ReadCells emits GEOMETRY('EPSG:4326') while catalog GeoParquet is GEOMETRY('OGC:CRS84'), so the join needs ST_SetCRS; and unfamiliar rasters need a cols*rows check before read, with the hex asset as the answer for anything large.
1 parent 96d0d91 commit eb331eb

7 files changed

Lines changed: 318 additions & 0 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Build experimental Docker image
2+
3+
# Builds ghcr.io/boettiger-lab/mcp-data-server:experimental from
4+
# Dockerfile.experimental — the default image plus the `raster` and `zarr`
5+
# community extensions (issue #354).
6+
#
7+
# Deliberately NOT triggered by pushes to main. This tag backs a single testing
8+
# endpoint (experimental-duckdb-mcp) and is meant to move only when someone is
9+
# actually exercising it; an automatic rebuild on every main push would swap the
10+
# image under a live test session.
11+
#
12+
# BASE_TAG selects which default image to layer on. It defaults to :main, so the
13+
# experimental build inherits main's dependency and stock-extension layers while
14+
# `COPY . /app` overlays the code from *this* ref — that is what lets the tag carry
15+
# server changes before they merge. When main's deps move, re-run this workflow.
16+
on:
17+
push:
18+
branches: ['experimental/**']
19+
workflow_dispatch:
20+
inputs:
21+
base_tag:
22+
description: 'Default image tag to layer on (e.g. main, v0.8.12, or a sha)'
23+
required: false
24+
default: 'main'
25+
26+
jobs:
27+
build:
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read
31+
packages: write
32+
steps:
33+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
34+
35+
- name: Log in to GHCR
36+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
37+
with:
38+
registry: ghcr.io
39+
username: ${{ github.actor }}
40+
password: ${{ secrets.GITHUB_TOKEN }}
41+
42+
- name: Set up Buildx
43+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
44+
45+
- name: Build and push
46+
id: build
47+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
48+
with:
49+
context: .
50+
file: Dockerfile.experimental
51+
push: true
52+
tags: ghcr.io/boettiger-lab/mcp-data-server:experimental
53+
build-args: |
54+
BASE_TAG=${{ github.event.inputs.base_tag || 'main' }}
55+
APP_VERSION=experimental
56+
GIT_SHA=${{ github.sha }}
57+
# Always pull, so a moving BASE_TAG (:main) is actually re-resolved
58+
# rather than served from a stale local base.
59+
pull: true
60+
cache-from: type=gha
61+
cache-to: type=gha,mode=max
62+
63+
- name: Smoke-test image
64+
run: |
65+
image="ghcr.io/boettiger-lab/mcp-data-server@${{ steps.build.outputs.digest }}"
66+
docker pull "$image"
67+
# Stock extensions must still work, and both extras must load and expose
68+
# their entry points — a silently-missing extension would otherwise only
69+
# surface as a confusing "function does not exist" at query time.
70+
docker run --rm "$image" python -c "
71+
import duckdb
72+
c = duckdb.connect()
73+
for e in ('httpfs', 'spatial', 'h3', 'raster', 'zarr'):
74+
c.sql(f'LOAD {e}')
75+
c.sql('SELECT h3_latlng_to_cell(37.8, -122.4, 5)')
76+
c.sql('SELECT ST_Point(0, 0)')
77+
for fn in ('RT_ReadCells', 'read_zarr'):
78+
n = c.sql(f\"SELECT count(*) FROM duckdb_functions() WHERE function_name ILIKE '{fn}'\").fetchone()[0]
79+
assert n, f'{fn} missing'
80+
print('OK: httpfs/spatial/h3 + raster/zarr loaded and functional')
81+
"
82+
83+
- name: Report image digest
84+
run: |
85+
{
86+
echo '### Experimental image digest'
87+
echo '```'
88+
echo "${{ steps.build.outputs.digest }}"
89+
echo '```'
90+
} >> "$GITHUB_STEP_SUMMARY"

Dockerfile.experimental

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Experimental image: the default server plus the `raster` and `zarr` community
2+
# extensions. Published as :experimental, deployed to experimental-duckdb-mcp
3+
# (k8s/experimental-*.yaml). Never promoted to the default endpoint — issue #354.
4+
#
5+
# Layered on the default image rather than duplicating it, so the Python deps and
6+
# the stock httpfs/spatial/h3 extension layer are shared and cannot drift. CI passes
7+
# BASE_TAG=<sha> so the experimental image is pinned to the exact base build from
8+
# the same run, not to whatever :main happens to be at pull time.
9+
ARG BASE_TAG=main
10+
FROM ghcr.io/boettiger-lab/mcp-data-server:${BASE_TAG}
11+
12+
# Community extensions. These pull GDAL into the query path (raster) and add a
13+
# chunked-array reader (zarr) — the reason this image is kept separate from the
14+
# default one rather than gated behind an env var alone.
15+
RUN python -c "import duckdb; c = duckdb.connect(); c.sql('INSTALL raster FROM community; INSTALL zarr FROM community')"
16+
17+
# Overlay this branch's code on the base image's dependency layers, so the
18+
# experimental tag can carry server changes that have not landed on main yet.
19+
COPY . /app
20+
21+
# Read by server.py: LOADs these per connection and injects
22+
# experimental-extensions.md into the query tool description. Unset on the
23+
# default image, which therefore behaves exactly as before.
24+
ENV EXTRA_DUCKDB_EXTENSIONS=raster,zarr
25+
26+
ARG APP_VERSION=experimental
27+
ARG GIT_SHA=unknown
28+
ENV APP_VERSION=$APP_VERSION GIT_SHA=$GIT_SHA
29+
30+
CMD ["python", "server.py"]

experimental-extensions.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
2+
### 🧪 EXPERIMENTAL EXTENSIONS (this deployment only)
3+
4+
This endpoint loads two community extensions beyond the stock set. They are **not**
5+
available on the default endpoint — do not assume them elsewhere.
6+
7+
#### `raster` — read GeoTIFF/COG directly
8+
9+
`RT_ReadCells(path)` returns **one row per pixel**: `(id, x, y, geometry, col, row, band_1, …)`.
10+
`RT_Read(path)` returns one row per tile with an `RT_DATACUBE` band, for band algebra
11+
(`RT_CubeStats`, `RT_CubeClip`, `RT_CubeBurn`). Paths may be S3/HTTP via GDAL's
12+
`/vsicurl/` or `/vsis3/` prefixes.
13+
14+
**Size guard — `RT_ReadCells` materializes every pixel.** A 92×81 fire-severity
15+
raster is 7,452 rows and returns in seconds; a continental COG is billions and will
16+
exhaust the pod. Before reading an unfamiliar raster, check its dimensions with
17+
`SELECT cols, rows FROM RT_Read(path)` and stop if `cols * rows` exceeds ~10 million.
18+
For anything larger, use the dataset's `…/hex/h0=*/…` asset — every raster collection
19+
in the catalog is already aggregated to H3 by the ingest pipeline, and that path is
20+
partition-pruned and far cheaper. `RT_ReadCells` is for small rasters not yet ingested.
21+
22+
⚠️ **CRS type mismatch.** `RT_ReadCells` emits `GEOMETRY('EPSG:4326')`; catalog
23+
GeoParquet is `GEOMETRY('OGC:CRS84')`. Both hold lon/lat, but DuckDB rejects the join
24+
on the type difference. Re-tag the raster side — no reprojection is involved:
25+
26+
```sql
27+
WITH px AS (
28+
SELECT band_1 AS value, ST_SetCRS(geometry, 'OGC:CRS84') AS g
29+
FROM RT_ReadCells('/vsicurl/https://example.org/severity.tif')
30+
WHERE band_1 > 0 -- drop nodata BEFORE the join
31+
)
32+
SELECT f.name, count(*) AS n_px, round(avg(px.value), 3) AS mean_value
33+
FROM px LEFT JOIN read_parquet('<geoparquet>') f ON f.geom.ST_Contains(px.g)
34+
GROUP BY 1;
35+
```
36+
37+
Filter the raster's nodata sentinel in the CTE (see §7) — it is usually a large
38+
negative value like `-9999` that will wreck any average.
39+
40+
For area, do not multiply a pixel count by a constant in a geographic CRS: pixel
41+
ground area varies with latitude. Assign cells with `h3_latlng_to_cell(y, x, res)`
42+
and sum `h3_cell_area()`, or transform to a projected CRS with
43+
`always_xy := true` (see §5).
44+
45+
#### `zarr` — read Zarr stores
46+
47+
`read_zarr(path)`, plus `read_zarr_groups(path)` and `read_zarr_metadata(path)` for
48+
discovery. Call the metadata/groups functions first to learn array names, shapes and
49+
chunking; the same size discipline as `RT_ReadCells` applies — a Zarr array is
50+
typically a full datacube, so slice on its coordinate columns before aggregating.
51+
52+
**Report which extension produced a number** when you use either one, so the user
53+
knows the result came from the experimental path.

k8s/experimental-deployment.yaml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Testing endpoint for the :experimental image (raster + zarr extensions, issue #354).
2+
# Not part of the prod or dev rotation — nothing should depend on this being up.
3+
apiVersion: apps/v1
4+
kind: Deployment
5+
metadata:
6+
name: experimental-duckdb-mcp
7+
namespace: biodiversity
8+
spec:
9+
# Single replica on purpose: a testing endpoint on a shared cluster, and a
10+
# single pod keeps a test session pinned to one DuckDB process.
11+
replicas: 1
12+
selector:
13+
matchLabels:
14+
app: experimental-duckdb-mcp
15+
template:
16+
metadata:
17+
labels:
18+
app: experimental-duckdb-mcp
19+
spec:
20+
affinity:
21+
nodeAffinity:
22+
requiredDuringSchedulingIgnoredDuringExecution:
23+
nodeSelectorTerms:
24+
- matchExpressions:
25+
- key: topology.kubernetes.io/region
26+
operator: In
27+
values: ["us-west"]
28+
containers:
29+
- name: server
30+
image: ghcr.io/boettiger-lab/mcp-data-server:experimental
31+
imagePullPolicy: Always
32+
env:
33+
- name: STAC_CATALOG_URL
34+
value: "https://s3-west.nrp-nautilus.io/public-data/stac/catalog.json"
35+
- name: MCP_PUBLIC_BASE_URL
36+
value: "https://experimental-duckdb-mcp.nrp-nautilus.io"
37+
- name: STAC_ALLOW_DEGRADED_START
38+
value: "true"
39+
# Also baked into the image; set here too so `kubectl describe` shows which
40+
# extras a running pod believes it has without cracking open the image.
41+
- name: EXTRA_DUCKDB_EXTENSIONS
42+
value: "raster,zarr"
43+
- name: POD_MEMORY_LIMIT
44+
valueFrom:
45+
resourceFieldRef:
46+
containerName: server
47+
resource: limits.memory
48+
resources:
49+
# Small requests (NRP's "Ignored" utilization bucket), and limits well
50+
# below prod's 160Gi/64: this endpoint is for small rasters and Zarr
51+
# slices. RT_ReadCells materializes every pixel, so a modest ceiling is
52+
# a feature — an over-large read fails here instead of evicting
53+
# co-tenants on the node.
54+
requests:
55+
memory: 2Gi
56+
cpu: 250m
57+
limits:
58+
memory: 32Gi
59+
cpu: 16
60+
ports:
61+
- containerPort: 8000
62+
readinessProbe:
63+
httpGet:
64+
path: /healthz
65+
port: 8000
66+
initialDelaySeconds: 15
67+
periodSeconds: 5
68+
timeoutSeconds: 2
69+
failureThreshold: 3
70+
livenessProbe:
71+
httpGet:
72+
path: /healthz
73+
port: 8000
74+
initialDelaySeconds: 30
75+
periodSeconds: 30
76+
timeoutSeconds: 10
77+
failureThreshold: 6

k8s/experimental-ingress.yaml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
apiVersion: networking.k8s.io/v1
2+
kind: Ingress
3+
metadata:
4+
name: experimental-mcp-server-duckdb-ingress
5+
namespace: biodiversity
6+
annotations:
7+
haproxy-ingress.github.io/cors-enable: "true"
8+
haproxy-ingress.github.io/cors-allow-origin: "*"
9+
haproxy-ingress.github.io/cors-allow-methods: "GET, POST, OPTIONS"
10+
haproxy-ingress.github.io/cors-allow-headers: "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,mcp-session-id"
11+
haproxy-ingress.github.io/cors-allow-credentials: "true"
12+
haproxy-ingress.github.io/cors-max-age: "86400"
13+
haproxy-ingress.github.io/balance-algorithm: leastconn
14+
haproxy-ingress.github.io/timeout-client: "600s"
15+
haproxy-ingress.github.io/timeout-server: "600s"
16+
haproxy-ingress.github.io/timeout-tunnel: "3600s"
17+
spec:
18+
ingressClassName: haproxy
19+
tls:
20+
- hosts:
21+
- experimental-duckdb-mcp.nrp-nautilus.io
22+
rules:
23+
- host: experimental-duckdb-mcp.nrp-nautilus.io
24+
http:
25+
paths:
26+
- path: /
27+
pathType: Prefix
28+
backend:
29+
service:
30+
name: experimental-mcp-server-duckdb
31+
port:
32+
number: 8000

k8s/experimental-service.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: experimental-mcp-server-duckdb
5+
namespace: biodiversity
6+
spec:
7+
selector:
8+
app: experimental-duckdb-mcp
9+
ports:
10+
- protocol: TCP
11+
port: 8000
12+
targetPort: 8000

server.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ def parse_setup_sql(content):
7777
H3_RAW = load_text_file("h3-guide.md")
7878
ROLE_RAW = load_text_file("assistant-role.md")
7979

80+
# Opt-in DuckDB extensions beyond the stock httpfs/spatial/h3 set (issue #354).
81+
# The experimental image (Dockerfile.experimental) installs raster + zarr and sets
82+
# EXTRA_DUCKDB_EXTENSIONS; the default image leaves it unset and behaves exactly as
83+
# before. LOAD is per-connection, like every statement in SETUP_SQL, so this is a
84+
# deployment knob rather than a code fork. Names are restricted to identifier
85+
# characters — they are interpolated into LOAD, which takes no parameters.
86+
_EXT_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")
87+
EXTRA_EXTENSIONS = [
88+
e for e in (s.strip() for s in os.environ.get("EXTRA_DUCKDB_EXTENSIONS", "").split(","))
89+
if e and _EXT_NAME_RE.match(e)
90+
]
91+
# Guidance for the extras is injected only where they are actually loaded, so the
92+
# default deployment's tool description is unchanged.
93+
EXTRAS_RAW = load_text_file("experimental-extensions.md") if EXTRA_EXTENSIONS else ""
94+
8095
# -------------------------------------------------------------------------
8196
# 3. CONTEXT INJECTION (PROMPT ENGINEERING)
8297
# -------------------------------------------------------------------------
@@ -111,6 +126,7 @@ def parse_setup_sql(content):
111126
112127
### 📐 H3 SPATIAL MATH
113128
{H3_RAW}
129+
{EXTRAS_RAW}
114130
---
115131
"""
116132

@@ -154,6 +170,14 @@ def get_isolated_db(s3_key: str = None, s3_secret: str = None, s3_endpoint: str
154170
conn.sql(stmt)
155171
except Exception as e:
156172
print(f"⚠️ Setup statement skipped: {stmt!r}: {e}", file=sys.stderr)
173+
# Opt-in extras (experimental image only — empty list on the default tag).
174+
# Warn rather than raise: a missing extension should degrade this connection's
175+
# capability, not fail every query the replica serves.
176+
for ext in EXTRA_EXTENSIONS:
177+
try:
178+
conn.sql(f"LOAD {ext}")
179+
except Exception as e:
180+
print(f"⚠️ Extra extension {ext!r} not loaded: {e}", file=sys.stderr)
157181
# Bound memory to the pod so a big aggregate spills instead of OOM-killing
158182
# the replica (#270). Not part of SETUP_SQL: the value is deployment-derived
159183
# (dbconfig reads POD_MEMORY_LIMIT/DUCKDB_MEMORY_LIMIT), not model guidance.

0 commit comments

Comments
 (0)