The
.mdfiles in this repo are runtime prompt artifacts, not documentation.server.pyloads them at startup and injects their content into MCP tool descriptions and prompts. Editing them changes what the agent is instructed to do.README.mdis the only human-facing documentation.
Dev is the pre-prod canary: it tracks main and is never repointed by hand.
dev-duckdb-mcp.nrp-nautilus.io runs the current :main build (pinned by digest,
≥2 replicas) precisely so merged changes are exercised on real infrastructure —
including cross-pod skew — before prod. All MCP/guidance testing runs against
dev, never a local process. Dev has one job; it is not a place to host a
branch. Repointing dev to a branch stomps main and decays into the hand-pinning
loop that keeps breaking it (#341/#366).
To test a change before it merges, do not repoint dev. Options:
- Merge-then-validate (works today): merge to
main→ dev rolls onto that digest → run the headless matrix withMCP_URL=dev. This is the current gate. - Preview env (planned, #366): a
previewlabel builds:pr-<n>for an ephemeraldev-pr-<n>— the build trigger and CI deploy land with the NRP deploy token. - Guidance PR gate (planned, #245): the matrix Job runs the PR's
server.pyas a sidecar (git-checkout of the branch — no image build) so the exact PR guidance is validated pre-merge.
Never run server.py (the MCP server) locally in this JupyterLab environment.
It loads DuckDB plus the full STAC catalog in-process; running it here OOM'd the shared
pod and took down the instance (2026-07-07). There is no "local MCP" testing path —
if you catch yourself starting server.py, uvicorn, or a wrapper that imports the
server on this machine, stop. The workflow is: land the change on dev (dev tracks
:main — see Rollout below), then validate on dev with the headless matrix
(see Validating guidance changes).
When NRP Ceph (s3-west.nrp-nautilus.io) is down, don't improvise a fallback:
follow docs/guide/mirror-failover.md — the single
runbook for the MinIO mirror and the mirror-configured head at
duckdb-mcp.carlboettiger.info. Its "For agents querying through MCP" section is
the short version: the normal STAC tools work on a mirror head; use the paths the
tools return verbatim. Older outage notes elsewhere describing a source.coop route
are superseded.
This repo uses GitHub Flow: all changes go through a branch + PR, never committed directly to main. main has branch protection enforced — direct pushes are rejected.
- Create a branch for your change (
git switch -c <branch>) before the first commit. Never commit onmainand open a PR afterward — the squash-merge will leave localmainpermanently diverged fromorigin/main. - Open a PR against
main - Merge via the GitHub UI (squash merge preferred)
This repo squash-merges, so the merged commit on origin/main has a different SHA than the local feature commits. Clean up with reset, not pull:
git switch main
git fetch origin
git reset --hard origin/main
git branch -D <feature-branch>
Do not git pull on main when local main has commits matching the just-merged PR — pull will create a merge commit because the squash changed the SHA. If you must pull, use git pull --ff-only so divergence fails loudly instead of silently merging.
The MCP server runs on the NRP Nautilus Kubernetes cluster.
- Prod:
https://duckdb-mcp.nrp-nautilus.io—k8s/deployment.yaml(replica count and image pin live there) - Dev:
https://dev-duckdb-mcp.nrp-nautilus.io—k8s/dev-deployment.yaml; must stay ≥2 replicas so cross-pod bugs surface here before prod - Resources: 16 Gi RAM requested, up to 160 Gi / 16 CPU per pod
- STAC catalog:
https://s3-west.nrp-nautilus.io/public-data/stac/catalog.json(set viaSTAC_CATALOG_URLenv var) - Ingress: HAProxy with CORS enabled, 10-minute query timeout, 1-hour SSE tunnel timeout
The server runs FastMCP in stateless streamable-HTTP mode (server.py: FastMCP(..., stateless_http=True)). Every POST /mcp is a complete, independent request/response. There is no Mcp-Session-Id pinning clients to a replica, no per-pod session cache, no in-memory state that survives across requests. The protocol's stateful SSE mode is not in use here.
This is intentional and load-bearing. Several things depend on it:
- The Service has
sessionAffinity: Noneand the ingress usesbalance-algorithm: leastconn. Both rely on the stateless premise — replicas are interchangeable on a per-request basis. - Each query runs in a fresh
duckdb.connect(":memory:")(the Isolation Engine,server.py§4). No connection, credential, or DuckDB state survives between requests. - Durable cross-pod state for genuinely persistent artifacts (e.g. hex tile pyramid build markers, PRs #146–#148) lives in S3 markers, not pod memory, for exactly this reason.
Do not introduce per-pod in-memory caches keyed on something the client provides expecting the same pod will see the next request — under stateless HTTP it almost certainly won't, and even if it did once, scaling up replicas or a single rollout breaks the assumption silently.
Application code is baked into the image (COPY . /app in the Dockerfile); pods no
longer git clone at startup. docker.yml builds on every push to main and on vX.Y.Z
release tags. The image is the unit of release.
Tags CI produces:
:main— moving; rebuilt on every push tomainand by the weekly cron. dev tracks this.:<git-sha>— immutable; one per commit.:vX.Y.Z— immutable; built on release tags. prod pins this (by digest, below).
Merge to main → redeploy dev (promote by digest — same discipline as prod):
Dev pins an immutable :main@sha256:… (tag for humans, digest enforced), not the
bare moving :main tag. :main + imagePullPolicy: Always is non-convergent: Always
re-resolves the mutable :main → digest independently per pod at each (re)start, and
:main moves on every push to main plus the weekly cron. So pods brought up seconds
apart, or any single later liveness/eviction/crash/cron restart, silently drift onto
different builds — the exact cross-pod skew dev's ≥2 replicas exist to catch (issue #341).
Pinning a digest makes every dev pod run one reproducible build.
- Wait for the
docker.ymlrun on your merge to go green first — rolling before the image is pushed givesImagePullBackOff. - Read the freshly-built
:maindigest from the build run's job summary, or:docker buildx imagetools inspect ghcr.io/boettiger-lab/mcp-data-server:main --format '{{.Manifest.Digest}}' - Set
image: ghcr.io/boettiger-lab/mcp-data-server:main@sha256:<digest>ink8s/dev-deployment.yaml(keepimagePullPolicy: IfNotPresent). kubectl apply -f k8s/dev-deployment.yamlkubectl rollout restart deployment/dev-duckdb-mcp -n biodiversity- Verify convergence (below) — this is dev's canary job; do not skip it.
Tag a release → redeploy prod (promote by digest):
git tag vX.Y.Z && git push origin vX.Y.Z, then wait fordocker.ymlto build:vX.Y.Z.- Read the digest from the build run's job summary, or:
docker buildx imagetools inspect ghcr.io/boettiger-lab/mcp-data-server:vX.Y.Z --format '{{.Manifest.Digest}}' - Set
image: ghcr.io/boettiger-lab/mcp-data-server:vX.Y.Z@sha256:<digest>ink8s/deployment.yaml(separate commit). kubectl apply -f k8s/deployment.yamlkubectl rollout restart deployment/duckdb-mcp -n biodiversity
prod pins an immutable :vX.Y.Z@sha256:… (tag for humans, digest enforced — if they ever
disagree, the digest wins). Never apply prod while the manifest points at an image CI
hasn't built yet — the rollout stalls on ImagePullBackOff. kubectl apply must precede
rollout restart; a git push alone does not update the cluster.
No docker CLI (e.g. a JupyterLab session)? imagetools inspect needs it, and the
GHCR packages API needs a read:packages token most of our gh logins don't carry. An
anonymous pull token against the registry v2 API works anywhere curl does — substitute
main or vX.Y.Z for <tag>:
repo=boettiger-lab/mcp-data-server
tok=$(curl -s "https://ghcr.io/token?scope=repository:$repo:pull" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -sI -H "Authorization: Bearer $tok" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://ghcr.io/v2/$repo/manifests/<tag>" | grep -i '^docker-content-digest:'
Sanity-check the method before trusting it: run it on the tag prod already pins and
confirm the digest matches k8s/deployment.yaml.
Verify all replicas converge on a single digest after rollout (both dev and prod —
dev is the multi-replica canary, so its convergence matters as much as prod's). Swap the
label for the deployment you rolled (duckdb-mcp for prod, dev-duckdb-mcp for dev):
kubectl -n biodiversity get pods -l app=duckdb-mcp \
-o custom-columns='NAME:.metadata.name,IMAGE:.status.containerStatuses[0].imageID'
Every pod that serves traffic must report the same imageID digest, matching the
manifest. But containerStatuses[0].imageID is a stale witness on a pod whose node is
unreachable: a disrupted-node orphan from an old ReplicaSet keeps Running with its
last-known status — often ready: true and an old digest — so the command above can
list mismatched digests that are not a live convergence failure. (Observed on prod
2026-08-12: three weeks-old orphans on disrupted nodes, containerStatuses[0].ready a
stale true, sitting on v0.8.8/0.8.9-era digests alongside six converged v0.8.15 pods.)
Check what actually serves, not the raw pod list:
- Service endpoints are authoritative —
kubectl -n biodiversity get endpoints mcp-server-duckdb(prod) ordev-mcp-server-duckdb(dev) lists only the pods receiving traffic. Orphans are excluded because their pod-levelReadycondition isFalse, even when the container's ownreadyis a staletrue. So the real check is: every endpoint-backing pod converges on the manifest digest. - To explain a mismatched digest, read the pod-level Ready condition
(
.status.conditions[?(@.type=="Ready")].status) and the node — an orphan readsReady=Falseon an unreachable node.
Such orphans may be Running, not just Terminating. Either way they are out of rotation
and NRP GCs them when the node recovers — never force-delete them (NRP ops policy).
This project uses two distinct, asynchronous AI agent processes. Do not confuse them.
A lightweight open-source LLM serves user requests in real time via the MCP tool. It has no memory between requests. Its only context is what is injected at call time.
Files it reads (injected as prompts):
query-setup.md— required DuckDB setup SQL, must run before every queryquery-optimization.md— short, actionable query-writing rulesh3-guide.md— H3 hex data model and per-problem patterns (tiling, overlap, raster, lines)datasets.md— STAC catalog summary, dataset paths, column schemasassistant-role.md— role and response style instructions
Rules for editing these files:
- Write for a small, stateless model with limited context
- Instructions must be short, concrete, and unambiguous
- No debugging advice, no explanation of DuckDB internals, no "check X before Y"
- No meta-commentary about why rules exist — just the rules
- Every SQL example must be the correct shape. Never include ❌ / antipattern SQL blocks — small LLMs reproduce the structure verbatim (variable names, CTE shape) regardless of the ❌ marker. Negation priming overrides the marker. State the rule in prose; show only the correct query. See PR #134 / Issue #133 for measured 2× reduction in antipattern rate after removing ❌ blocks.
- Prefer positive framing in bullets: "Do X first, then Y" beats "❌ Don't Y before X". Existing prose "Never X" rules without SQL bodies can stay — the hazard is SQL antipatterns, not the word "never."
- When adding guidance for a niche case (e.g., line datasets when most datasets are polygons), keep it short and gate with a "Skip if your dataset is X" header so models doing the common case skim past. Niche fixes must not displace attention from the dominant workload.
A separate, asynchronous process uses Claude to review logs from real user sessions, identify slow or incorrect queries, diagnose root causes, and update the small LLM's instructions.
Files it reads:
- Real-time query logs and timing data from production
optimization-design-notes.md— developer notes on DuckDB behavior and benchmarksAGENTS.md— this file
Files it writes:
query-optimization.md,query-setup.md— updated instructions for the small LLMoptimization-design-notes.md— updated technical findings
Rules for this process:
- When a query is slow, check whether it follows all rules in
query-optimization.mdbefore diagnosing infrastructure, DuckDB behavior, or S3 limitations - Benchmark queries used to test a rule must themselves follow that rule (e.g., a benchmark testing "h0 in join" must actually have h0 in the join)
- Do not edit
query-optimization.mdwith debugging checklists, developer notes, or explanations of DuckDB internals — that content belongs inoptimization-design-notes.md - A claim that a rule in
query-optimization.mdis wrong requires a correctly structured benchmark that violates only that rule and nothing else
All of query-optimization.md and h3-guide.md are injected verbatim into the
query tool description (TOOL_INJECTED_CONTEXT in server.py), right after the
hardcoded CRITICAL block. So putting a rule in the server.py CRITICAL block instead of
an .md file does not make it more available to the model — it's the same blob.
The only differences are maintainability (hardcoded Python vs the designated runtime
artifacts) and prominence (the CRITICAL block is first and stamped MUST-FOLLOW —
scarce attention that every addition dilutes).
So choose the home by what kind of fact it is, and pick the most specific layer that fits. Redundancy across layers is a cost (drift + diluted attention), not a feature — state a thing once, in one layer, and point to detail rather than copy it.
| Layer | Owns | Examples |
|---|---|---|
dataset STAC (in boettiger-lab/data-workflows) |
facts true of one dataset — surfaced by get_stac_details exactly when the model is choosing columns |
"this file repeats sites — dedup by ramsarid"; no-data sentinel codes; which column is the feature id; native resolution |
h3-guide.md |
general H3 / hex model & patterns | resolution direction, cell-area table, parent-resolution joins, multiple-rows-per-hex |
query-optimization.md |
general SQL-writing rules, cross-dataset | include h0 in joins, NaN poisons SUM, fuzzy vs exact text match |
| server.py CRITICAL block | the few foundational invariants, read first, ~never change | no tables, use read_parquet, trust STAC paths verbatim, DPP mask-before-aggregate |
| server.py code | runtime tool behavior | the 50-row truncation footer, geometry-column drop |
Decision rule when you catch a model error:
- A wrong answer caused by a property of one dataset (duplicate rows, sentinel values, an id column) → fix the dataset STAC, not the global prompt. A global rule asserting "geoparquet has duplicate feature rows" is false for clean files and pollutes every query; the per-dataset note is true exactly where it's needed.
- A general hex/SQL pattern → the matching
.mdfile. Use a concrete dataset only as an illustrative example of the general pattern, never as the rule itself. - The server.py CRITICAL block is not a priority flag. "This error is the most common" is not a reason to hardcode a rule there — that just crowds the invariants. Put the guidance in its proper layer; if it's a per-dataset fact, STAC is what surfaces it at the right moment.
Every section of h3-guide.md / query-optimization.md declares a tier in its prov
line. select_tiers() in server.py assembles the description from it:
| tier | who gets it | what belongs here |
|---|---|---|
core (default) |
everyone, including prod and dev | anything whose absence changes an answer |
extra |
only deployments setting EXTRA_INSTRUCTIONS=1 |
rules whose absence costs turns, not correctness, and whose motivating failure was last seen on a model outside the shipping set |
A section with no prov, or a prov with no tier=, is treated as core — guidance
is load-bearing until shown otherwise. Demoting a ## takes its ### children with it.
The bar for tier=extra is evidence, not intuition. Both current members
(query-optimization.md §3 DESCRIBE+LIKE, and the h3-guide rows-per-hex diagnostic) are
efficiency-only and qwen3-era. If a rule can produce a wrong number, it is core even
if only weak models get it wrong — the flag is off in production, so extra means "the
shipping models never see this."
Demotion is cheap to prove, which is the point. The weak models keep the text either
way, so a demotion only has to show the core models are unaffected: run the regression
tier on the standard set with the flag off and compare tool_call_count, which the
harness already records, against the same run before the change. Efficiency rules are
graded on turns, not accuracy — that A/B is a couple of free NRP jobs.
What tiering will not do is shrink the prompt much. Measured 2026-08-17: the two
demotions together are 1,300 ch of 51,720 (2.5%). The audit behind #384 is the reason —
of the sections originally flagged cell=none, nearly all turned out to be load-bearing
with live consumers, and the honest remedy was to write cells for them, not to demote them.
Treat the flag as a discipline for keeping weak-model-only text out of the shipping
description, not as a budget lever.
Every change to a prompt artifact (h3-guide.md, query-optimization.md,
query-setup.md, assistant-role.md, datasets.md) must be validated against the
open-model suite on dev before it ships to prod. It is not done until the matrix
shows both: (a) the targeted failure is fixed, and (b) no regression on the
standing baseline set (below). These files are runtime instructions for small open
models — the ground truth is not "does the SQL I write run," it's "does the model
generate the right SQL after reading this guidance." Only running the models confirms that. A fix verified solely by
running the corrected query yourself proves the pattern works, not that the guidance
steers the model to it.
Guidance is shared context: text added for one trap is read by every query. Tighter wording is safer wording — say it once, in the most-read place, and stop. Prefer editing an existing rule over adding a parallel one; never restate a rule the suite already passes.
The question bank, gold answers, and tiers live in boettiger-lab/geo-agent-benchmark
(private) — read its AGENTS.md for the full runbook. Execution is the harness in
boettiger-lab/open-llm-proxy under headless/, which replays the real geo-agent
tool-use loop from the command line against the LLM proxy, so prompt assembly, the
tool-call parser, and the MCP transport stay in sync with the browser app by
construction.
Workflow for a guidance change:
-
Merge the change and let dev pick it up — dev tracks
:mainand serves the injected guidance. (Confirm:curl -s https://dev-duckdb-mcp.nrp-nautilus.io/versiongit_sha matchesmain; the new text appears in thequerytool description.) -
Run the
regressiontier as one-shot k8s Jobs (they pullPROXY_KEYfrom the cluster Secret — no local creds, and reading that Secret locally is not the supported path).MCP_URLis what points a run at dev — every app repo commits anmcp_urlaimed at a production head, so a run without it measures the old guidance no matter what dev is serving:cd ../geo-agent-benchmark MCP_URL=https://dev-duckdb-mcp.nrp-nautilus.io/mcp \ APP_REPO=boettiger-lab/ca-30x30 TIER=regression \ MODELS="z-ai/glm-5.2" TRIALS=2 TAG=<short-tag> \ ./scripts/run_benchmark.sh
One Job per model runs them in parallel (one pod each,
MODELSholding a single value, distinctTAGs); one Job with several models runs them serially in one pod. The runner is per-app, so repeat per app repo to cover a whole tier. The tier already carries the trap gates and the standing baseline, so the fix and the regression check share one run. -
Read the transcripts from
kubectl -n biodiversity logs job/<JOB_NAME>: confirm (a) the models emit the intended SQL pattern and the correct answer and the prior failure form is gone (zero occurrences), and (b) every baseline question still resolves to its known-good answer — no question the change wasn't aimed at got worse. Check the Job log's--- mcp: ---line says dev before believing any of it. A regression on the baseline blocks promotion to prod even if the targeted trap is fixed — fix forward or revert on dev first.
Pick the models deliberately. Always include any model that previously exhibited
the failure (the logs naming the symptom are the regression set), plus a spread of the
suite. geo-agent-benchmark/suite/models.yaml is the model registry
(python3 scripts/models.py standard); an app's k8s/configmap.yaml picker is what
MODELS-unset measures — deployed behavior. Establish a ground-truth answer first by
running the correct query yourself, so the transcripts have something to check against.
The standing baseline is the regression tier in geo-agent-benchmark — a fixed
set of questions with golden answers (suite/gold/), re-run on every guidance change
to catch collateral damage, and graded mechanically (scripts/grade.py). Grow-on-fix:
when a change fixes a new trap, add the question that exposed it to the tier with a
trap: tag, so future changes can't silently reintroduce it.
To A/B-test app-level system prompt wording (not the MCP-injected guides) without
forking the app, run-matrix-k8s.sh takes SYSTEM_PROMPT_APPEND_FILE.
To validate a guidance change without merging, an agent started server.py locally in
the JupyterLab environment and pointed the headless runner at it. The server loads
DuckDB + the full STAC catalog in-process; the memory pressure OOM'd the shared pod and
crashed the instance. There is no local-MCP testing path — dev is the development
server. Land the change on dev and test there. See Development & test environment at
the top of this file.
A small LLM generated a query omitting h0 from a join (violating the rule in
query-optimization.md). The resulting slow query was diagnosed as "S3 DPP is broken"
rather than "the query violated the h0-in-join rule." Hours of investigation followed,
producing optimization-design-notes.md content that incorrectly characterized the
rule as wrong. A subsequent Claude session read those notes and tried to remove the
correct rule from query-optimization.md.
The fix is this document: keep the two processes and their files clearly separated, and diagnose rule violations before infrastructure.
Baseline matrix runs (issue #133) showed small LLMs reproducing the ❌-marked DPP antipattern CTE — same variable names, same shape — at a 50% first-query rate. The ❌ marker did not suppress reuse; if anything the named, copy-pasteable SQL primed it. PR #134 removed the ❌ blocks and hoisted the rule into the CRITICAL SQL RULES header, halving the antipattern rate.
When extending the prompt artifacts (e.g., adding a new "Problem N" section to
h3-guide.md), the temptation to mirror the structure of the existing problems —
which already showed ❌/✅ pairs — is high. Resist it. Even on guidance covering a
new failure mode (lines, sparse rasters, etc.), include only the ✅ form. Past sections
that still carry ❌ blocks are the next thing to migrate, not a pattern to copy.