duckmove
Ask spatial questions about your business data in plain English.
duckmove is a DuckDB-powered Model Context Protocol (MCP) server that lets any MCP client (Claude Desktop, Claude Code, …) analyze and map your geospatial data. It is built for data analysts and BI people — no GIS software, no cloud account, no SQL required (the LLM writes it for you).
- Local-first:
pip install duckmove, point your MCP client at it, done. Your data stays on your machine in a single DuckDB file. - Any format: CSV, TSV, Excel, Parquet, GeoJSON, JSON, Shapefile.
- Real engine: millions of rows are fine. Full DuckDB SQL plus the spatial extension (
ST_*functions, spatial joins, accurate WGS84 geodesic distances). - Maps: interactive folium maps with geofence overlays, served from a local preview page.
- Persistent & deduplicated: loaded tables survive restarts (
~/.duckmove/data.duckdb); re-loading the same file reuses the existing table instead of piling up copies.
Quick start
pip install duckmove # or: pip install -e . from a clone
duckmove init-claude --write --absolute # registers the server in Claude Desktop
duckmove doctor # verify everything works
Restart Claude Desktop, then try:
"Load
examples/shipments_gps.csvand tell me which shipment travelled the farthest."
The agent will call load_data, inspect the schema with describe_table, and write the SQL itself with run_sql.
Maps (optional but nice)
duckmove init-claude --write --absolute --set-preview-env
duckmove start-server # local preview page at http://127.0.0.1:8765
Now "map all delivery destinations" returns a clickable URL. Every generated map is listed on the preview homepage, newest first.
Tools
| Tool | What it does |
|---|---|
load_data |
Load a file (csv/tsv/xlsx/parquet/geojson/json/shp) into a table. Smart reuse: the same file unchanged is reused instantly; if it changed on disk it's refreshed in place — no duplicate _2 tables |
load_csv_text |
Load pasted CSV text (auto-named points/routes/… from the header) |
list_tables / describe_table |
Discover tables, schemas, samples, coordinate quality |
rename_table |
Rename a loaded table, e.g. shipments_gps → q3_shipments |
run_sql |
The workhorse. Read-only SQL with full DuckDB spatial support |
register_geofences |
Create a named geofence set from circles (name,lat,lon,radius_m), vertex polygons, or WKT. Pass tag_col (e.g. customer) to tag each fence so it can be filtered later |
list_geofence_sets / list_geofence_tags |
List geofence sets, and the tags (e.g. customers) within a set |
points_in_geofences |
Count points inside each fence (optionally filtered to one geofence_tag) |
geofence_crossings |
Per-group enter/exit events ordered by timestamp |
map_points |
Interactive point map. color_by a column (numeric scale or categories, with legend), size_by a numeric column, and style = markers / heatmap / clustered |
map_routes |
Origin→destination lines from a table or SQL query |
map_tracks |
Draw each group's path (e.g. per shipment, ordered by timestamp) as a colored line labelled with its total distance |
save_query / run_saved / list_saved |
Save and rerun favorite analyses |
help |
Tool roster + example prompts |
All three map tools accept a table name or a SQL query, and an optional geofence_set + geofence_tag to overlay just one tag's (e.g. one customer's) fences.
Spatial SQL crash course
run_sql accepts any read-only DuckDB query. Useful spatial idioms:
-- Geodesic distance in km between two coordinate pairs (WGS84).
-- NOTE: ST_Distance_Spheroid expects ST_Point(latitude, longitude).
SELECT ST_Distance_Spheroid(ST_Point(40.7128, -74.0060),
ST_Point(34.0522, -118.2437)) / 1000.0 AS km;
-- Total distance per shipment from ordered GPS pings
WITH hops AS (
SELECT shipment_id,
ST_Distance_Spheroid(
ST_Point(lat, lon),
ST_Point(lag(lat) OVER w, lag(lon) OVER w)) AS m
FROM shipments_gps
WINDOW w AS (PARTITION BY shipment_id ORDER BY "timestamp")
)
SELECT shipment_id, round(sum(m) / 1000.0, 1) AS total_km
FROM hops GROUP BY shipment_id ORDER BY total_km DESC;
Results with GEOMETRY columns come back as WKT text, and queries are capped (default 500 rows) with a truncation note.
Example data
The examples/ folder ships realistic dummy datasets:
shipments_gps.csv— GPS pings (shipment_id, customer, timestamp, lat, lon, carrier, status)geofences.csv— circles + a polygon corridor forregister_geofencescustomer_geofences.csv— geofences with acustomercolumn, fortag_col/geofence_tagfilteringsample_shipments.csv/shipments_legs.csv— origin→destination legsstores.csv/stores.xlsx— retail stores with revenue (BI-style)deliveries.parquet— 200 deliveries for Parquet loading and SQL practice
See examples/README.md for prompt ideas. Regenerate with python scripts/make_dummy_data.py.
CLI reference
duckmove serve(aliasstart) — run the MCP server over stdioduckmove init-claude(aliassetup-claude) — print/merge Claude Desktop config--writemerge into config (with.bakbackup),--absoluteuse the current Python path,--set-preview-envadd preview env vars
duckmove doctor— check Python, duckdb + spatial, folium, and Claude configduckmove preview/duckmove start-server— serve generated maps over local HTTP
Environment variables:
| Variable | Purpose |
|---|---|
DUCKMOVE_DB |
Database file (default ~/.duckmove/data.duckdb) |
DUCKMOVE_PREVIEW_DIR / DUCKMOVE_PREVIEW_URL |
Where rendered maps are written and served from |
DUCKMOVE_ALLOWED_DIRS |
Restrict load_data to these directories (OS path separator delimited) |
DUCKMOVE_LOG_LEVEL |
DEBUG/INFO/WARNING (default WARNING); logs go to stderr |
If duckmove is not on PATH, use python -m duckmove.cli <command>.
Security & limits
run_sql cannot touch the filesystem or the network. An LLM-driven SQL
tool that can call read_csv('~/.aws/credentials') is an exfiltration
primitive — text hidden in a loaded spreadsheet is enough to trigger it. So
the two capabilities are separated:
-
load_datais the only path to disk. It takes a path the user named and binds it as a query parameter, so it never passes through the SQL policy. -
Everything the model composes itself —
run_sql, maptable_or_sqlarguments, saved queries — is checked bycore/sqlguard.py, which rejects:- anything but
SELECT/WITH/DESCRIBE/SUMMARIZE/SHOW/EXPLAIN; - writes and side effects (
DROP,ATTACH,COPY,INSTALL,PRAGMA,SET, …) anywhere in the statement, not just as the first word; - every file/network reader (
read_*,ST_Read,glob,parquet_scan,iceberg_scan,postgres_scan,getenv, …) and bare paths inFROM; - multiple statements.
Checks run with string literals masked, so a payload inside a quoted value can neither smuggle a keyword past the scan nor falsely trip it.
- anything but
Additional limits:
- Set
DUCKMOVE_ALLOWED_DIRSto confineload_datato specific directories. Unset (the default), it loads any path the user explicitly names. - Results are capped at 10,000 rows regardless of the requested
max_rows, withtruncatedand an explanatorynotein the response. - The local preview server rejects cross-origin
POSTs, so a page in your browser cannot delete or rename your maps behind your back. - Tool failures return
{error, error_code}— never a stack trace — with the available tables/columns so the agent can self-correct.
Run duckmove doctor to see the active policy.
Development
python -m venv .venv && .venv/Scripts/activate # Windows
pip install -e ".[dev]"
python -m pytest # 200 tests; no editable install required
python -m pytest --cov=duckmove # with coverage
ruff check src tests # lint
mypy # types
CI (.github/workflows/ci.yml) runs the suite on Linux/macOS/Windows across
Python 3.9–3.13, plus lint and a package build.
Releasing to PyPI
Releases are published automatically by GitHub Actions (.github/workflows/publish.yml)
using PyPI Trusted Publishing — no API tokens are stored anywhere.
One-time setup (PyPI account owner):
- On PyPI → Your projects → Publishing → Add a pending publisher:
- PyPI Project Name:
duckmove - Owner:
xoniks· Repository:duckmove - Workflow name:
publish.yml· Environment:pypi
- PyPI Project Name:
- In the GitHub repo → Settings → Environments → create an environment named
pypi.
To cut a release:
- Bump
versioninpyproject.tomland commit. - On GitHub → Releases → Draft a new release, create a tag like
v0.1.0, publish it. - The workflow runs tests, builds,
twine checks, and publishes to PyPI via OIDC.
A local build for inspection: python -m build then python -m twine check dist/*.
Roadmap
- Phase 2 — Snowflake:
import_from_snowflake(query)materializes a warehouse query as a local table (snowflake-connector → Arrow → DuckDB), so analysts pull a slice once and iterate locally for free. - Geocoding: load data with addresses/city names and resolve coordinates automatically.
- Trust layer: surface the SQL that ran + a plain-English explanation, and a data-quality profile on load.
- MotherDuck attach; export results to CSV/Excel/GeoJSON/PNG.
License
MIT
Metadata
Release files for duckmove 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| duckmove-0.1.0.tar.gz | 61.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| duckmove-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 106.6 kB
Release files / duckmove-0.1.0.tar.gz
| Download URL | duckmove-0.1.0.tar.gz |
|---|---|
| Size | 61.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
163b906220fbaca0800e944d29fe488e887ac7fe9970717937c60376b2c64c0b
|
|
BLAKE2b-256 checksum How to use checksums |
ba16b5e6c243269cb6c18d09d96ea3f52ae104df4b43c263e3ef273b40d79049
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logRelease files / duckmove-0.1.0-py3-none-any.whl
| Download URL | duckmove-0.1.0-py3-none-any.whl |
|---|---|
| Size | 45.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e0ab58f6b5b3de2a1a8d416eb8eb797e96eb9c26c0c43051a26178313d09f8af
|
|
BLAKE2b-256 checksum How to use checksums |
b107c8a053648a133a1e53a008e9648410cc7df299dfa5a905e9fb416d363ad7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency log