mapchallenge-mcp
LLM-friendly GIS tools over the Model Context Protocol — plus jydestudio, a web studio where you chat with a GIS agent and see the results on a map. Built for the #30DayMapChallenge on FastMCP, geopandas and MapLibre.
Ask for "the roads in Akure", "500 evenly spread points in Lagos State", "the buildings in Akure South", "where are the hotspots?" or "the elevation of Akure South" — the tools fetch, clip and save real data as GeoJSON and GeoTIFF; the studio draws it, styles it (colour by column, 3D extrusion), shows elevation as 3D terrain with a "what can I see from here" viewpoint, and keeps every project's chats and files together.
Tools
| Tool | What it does |
|---|---|
fetch_boundary |
Any place name → its boundary polygon (OpenStreetMap / Nominatim) |
points_in_boundary |
N points inside a boundary — random or even (jittered grid) |
boundary_centroid |
Centre of a boundary — centroid or inside (always inside) |
points_hull |
Concave / convex outline around a set of points |
fetch_lines |
Rivers or roads inside a boundary (OSM Overpass), detail matched to area size |
fetch_buildings |
Building footprints inside a boundary (Overture Maps), with area_m2 · [overture] |
density_hotspots |
Heatmap (GeoTIFF + PNG) + hotspot outlines for any points, lines or polygons · [raster] |
fetch_point_values |
Values at points across a boundary: elevation (SRTM), weather (Open-Meteo), climate (NASA POWER) |
idw_surface |
Continuous surface from a numeric point column by IDW, with a leave-one-out error · [raster] |
fetch_elevation |
Elevation GeoTIFF for an area (Copernicus DEM GLO-30, only that area is downloaded) · [raster] |
load_spatial_file |
Load .geojson/.shp/.gpkg/.parquet/.csv → summary |
save_spatial_file |
Save inline GeoJSON or a file → GeoJSON (EPSG:4326) |
describe_dataset |
LLM-sized summary: counts, CRS, bounds, nulls, area/length |
reproject |
To EPSG:xxxx or auto-utm |
Slow downloads (busy Overpass, first Overture query) move to the background after 20 s: the agent says so, and a short note lands in the chat when the job finishes. Running jobs can be stopped from the chat or from the top bar.
Install
Requires Python ≥ 3.11.
pip install "mapchallenge-mcp[web,raster,overture]"
Extras: [web] = the studio (FastAPI + uvicorn), [raster] = heatmaps,
surfaces, elevation and 3D terrain (rasterio), [overture] = building
footprints (DuckDB). The plain package is the MCP server alone. Linux and
macOS.
For development:
git clone https://github.com/jydestudio/mapchallenge-mcp && cd mapchallenge-mcp
python -m venv .venv && . .venv/bin/activate
pip install -e ".[web,raster,overture,dev]"
pytest
Run
mapchallenge-mcp # MCP server over stdio (for Claude Desktop, etc.)
mapchallenge-mcp --http # studio at http://127.0.0.1:8321 (+ MCP at /mcp)
On first start the studio opens Providers & keys with a short guide: get a key from an OpenAI-compatible provider (OpenRouter, Google Gemini, River AI …), paste it, mark it active, pick a model, create a project and start asking. (Later: the gear icon in the top bar.)
HTTP mode + mapchallenge studio
Requires the [web] extra (FastAPI + uvicorn). Stdio mode is unchanged when
--http is not passed.
pip install -e ".[web]"
mapchallenge-mcp --http # serves on 127.0.0.1:8321
mapchallenge-mcp --http 9000 # custom port
- MCP endpoint: streamable-http at
http://127.0.0.1:8321/mcp— connect any MCP client that supports streamable-http transport (e.g. viamcp.client.streamable_http). - Studio:
http://127.0.0.1:8321/— the mapchallenge studio web UI (chat + agent transparency feed + providers + projects; see below). The legacy inline-HTML map gallery moved to/legacy-gallery— newest-first PNG list from~/.cache/mapchallenge/maps/, auto-refreshes every 3 s, works offline (no CDNs). Maps written byquick_mapalso drop a<name>.jsonsidecar (title, params, paths, timestamp) that the gallery displays; legacy PNGs without sidecars render fine via filename fallback. - PNG serving:
http://127.0.0.1:8321/maps/<file>.png— read-only,image/png, path-traversal safe (files are confined to the maps cache, PNGs only).
quick_map options (0.2.1)
quick_map grew two optional parameters (backward compatible — existing calls are
unchanged except that tick labels now default to DMS, which is the house style):
| Param | Default | Values | Effect |
|---|---|---|---|
coordinate_format |
"dms" |
"dms" / "decimal" |
Tick-label format for geographic (decimal-degree) axes: 5°30'20.2"N / 5.5056°N. On planar CRS (metres) the format cannot be applied — default numeric ticks are kept and the sidecar records a notes entry. |
style |
"plain" |
"plain" / "cartographic" |
"cartographic" applies the lifted cartography-engine annotations: DMS/decimal grid with inward top/right ticks, north arrow (packaged north_arrow.png), black/white scale bar, serif bold title, #f0f0f0 frame. |
quick_map(gdf, title="My map", style="cartographic") # house style
quick_map(gdf, coordinate_format="decimal") # decimal ticks
The sidecar JSON gains coordinate_format and style under params (plus notes
when a fallback kicked in). The MCP quick_map tool accepts both parameters too.
House style: maps meant for human consumption should use
style="cartographic" with the default coordinate_format="dms" — DMS tick labels
(e.g. 5°30'20.2"N), north arrow top-left, scale bar bottom-center, serif title.
"plain" remains the compact default for data exploration.
Built-in agent
mapchallenge-agent spawns the MCP server over stdio and drives a tool-calling
loop against any OpenAI-compatible chat API. Configuration is via environment
variables only — no keys are hardcoded or stored:
| Variable | Default | Notes |
|---|---|---|
MAPCHALLENGE_LLM_API_KEY |
(required) | missing → clean error, exit 2 |
MAPCHALLENGE_LLM_BASE_URL |
https://openrouter.ai/api/v1 |
e.g. http://localhost:11434/v1 for Ollama |
MAPCHALLENGE_LLM_MODEL |
openrouter/ling-2.6-flash |
any model your endpoint serves |
mapchallenge-agent --prompt "Describe the dataset at /path/to/file.geojson"
Streams the assistant's text to stdout and prints MAP: <absolute path> lines for
map paths returned by tools. --max-iters N caps tool-call iterations (default 10).
To try it for real, bring your own key (never commit it):
pip install -e .
export MAPCHALLENGE_LLM_API_KEY=<your OpenRouter key>
mapchallenge-agent --prompt "Create a 2x2 polygon grid as inline GeoJSON near Berlin (EPSG:4326), save it via save_spatial_file to /tmp/opencode/grid.geojson, then render it with quick_map titled 'Agent smoke test'"
For Ollama instead:
export MAPCHALLENGE_LLM_BASE_URL=http://localhost:11434/v1
export MAPCHALLENGE_LLM_MODEL=llama3.1
export MAPCHALLENGE_LLM_API_KEY=ollama # any non-empty string; Ollama ignores it
mapchallenge-agent --prompt "..."
mapchallenge studio (web UI — "jydestudio")
mapchallenge-mcp --http serves the studio at / (Vite + React + Tailwind;
bundled into the wheel as package data, no CDN, no system fonts — Fraunces and
Inter ship as woff2 inside the bundle). v0.7.0 "Claude calm": dark theme is
the default with a persisted light/dark toggle in the topbar's flat icon
cluster (warm paper remains the light palette), Anthropic clay accent
(#D97757 dark / #C6613F light — rationed to a handful of sanctioned spots,
ration rules locked by tests), Fraunces serif for headings and assistant
prose, and the quiet plain jydestudio wordmark (whole v2.2–v2.4 chrome log
in the CHANGELOG).
v2.2 UX structure:
- Landing = projects list at
/— cards (name, artifact counts, created, active badge), create-new, open. Chat is not the landing anymore. - Project workspace at
/project/<name>with tabs chat / map / collections (tab is part of the URL; deep links + back/forward work — the server serves the SPA for all/project/...paths). - Conversations survive navigation — chat state is lifted to project scope; leaving chat → map/collections → back restores the conversation exactly as left.
- Per-project history — every turn is persisted server-side under
<project>/data/conversations/<id>.json. The chat's History dropdown lists a project's transcripts (title, turns, updated); reopening replays the turns into the view and into the agent context (prior user/assistant messages +[map: …]/[geo: …]artifact lines), so follow-ups remember the session. "+ new chat" starts fresh. API:GET /api/projects/{name}/conversations(list) andGET /api/projects/{name}/conversations/{cid}(detail). POSTing to/api/agent/chatwith{"project": "...", "conversation_id": "..."}routes the turn into that transcript (and activates the project). - Responsive — folds cleanly at phone (~390px: icon-only tab strip, folded rails), tablet (768px) and laptop (1024px); modals stay centered and capped.
Views & features:
- Chat — GFM markdown answers (react-markdown + remark-gfm) in a serif palette with clay accent, dark code blocks, styled tables; inline tool-call chips under each answer (tap to expand) + an ephemeral thinking indicator (reasoning is never rendered in full or stored); inline auto-charts (see below); per-artifact download chips; "View on map" for geography artifacts; collapsible side pane with controls + per-project conversation history.
- Map — MapLibre GL with keyless basemaps (OSM raster for Standard, the
same OSM tiles CSS-inverted on the canvas for Dark, Esri World Imagery for
Satellite — attributions kept) and globe projection when zoomed out; loads
GeoJSON from the active project's
geometry/(+ agent-produced.geojsonfrom anywhere under the projects root/cache); pan/zoom, popups, feature/props table + full data table side panel; graceful offline state when tiles are unreachable (data renders on a plain canvas with a notice). Layers rail grouped by chat: an eye switch per file and per part (features, raster, boundary), several files on at once, GeoTIFF/PNG downloads, and a base height for any polygon layer. Heatmaps and surfaces draw with a legend and opacity slider. Elevation files turn on 3D terrain (height ×1–5); Pick viewpoint shades the land visible from a spot (5 km, 1.7 m eye, terrain only) and Stand here flies the camera to eye level there — drag to look around. - Collections — every rendered map, grouped project → date, fed by the sidecars next to each PNG; auto-refreshes every 4 s; per-map download button; per-project Download .zip (geometry + maps + data).
- Downloads —
GET /api/download?path=<abs>[&inline=1]per-artifact (attachment or inline for<img>),GET /api/projects/{name}/zipfor a project archive; absolute paths only, confined to the projects root and~/.cache/mapchallenge— path traversal is rejected. - Tool output wiring —
quick_map/save_spatial_filewrite into the active project'smaps/directory when one is set (cacheoutputs/is only a fallback when the projects layer is unusable). Sidecars land next to the artifact and record the project tag.
Theme binding: localStorage["mc-theme"] ("dark" default, "light" =
cream/ivory), applied pre-paint by an inline boot script (no flash). Studio
bind address defaults to 127.0.0.1; set MAPCHALLENGE_STUDIO_HOST=0.0.0.0
to expose it on all interfaces.
The legacy auto-refresh gallery moved to /legacy-gallery; /maps/{file}.png
serving is unchanged.
Build the frontend (from a clean checkout)
cd frontend
npm ci # installs pinned dependencies (~80 MB)
npm run build # writes ../src/mapchallenge/web_static/
cd ..
pip install -e ".[web]"
mapchallenge-mcp --http # studio at http://127.0.0.1:8321/
web_static/ is already built and shipped in the wheel, so this step is only
needed when changing the frontend. Frontend dev server (npm run dev) proxies
nothing — point the UI at a running backend manually.
Bring your own key (BYO)
Keys are stored only in ~/.config/mapchallenge/config.toml (mode 0600),
are masked to rv_…last4 in the UI, and never enter responses or logs.
Set them in the UI (gear icon → Providers & keys) or via the API:
# River AI (OpenAI-compatible) — keys from console.river.ai/keys
curl -X PUT localhost:8321/api/providers/river \
-H 'Content-Type: application/json' \
-d '{"api_key": "rv_your_key_here", "model": "deepseek-ai/DeepSeek-V4.1-Flash"}'
curl localhost:8321/api/models # live list from the active provider
Seeded providers (active = river by default):
| id | base_url | default model |
|---|---|---|
river |
https://api.river.ai/v1 |
deepseek-ai/DeepSeek-V4.1-Flash |
openrouter |
https://openrouter.ai/api/v1 |
openrouter/ling-2.6-flash |
google |
https://generativelanguage.googleapis.com/v1beta/openai/ |
gemini-2.0-flash |
The built-in agent uses the same config file when invoked from the web UI.
mapchallenge-agent (CLI) keeps its env-var configuration
(MAPCHALLENGE_LLM_*); the two paths are independent by design.
Projects
Studio workspaces live in ~/.local/share/mapchallenge/projects/<name>/ with
geometry/, maps/, data/ subfolders. default is auto-created on first
run; create/switch projects from the topbar or:
curl -X POST localhost:8321/api/projects -d '{"name": "census-2026"}' \
-H 'Content-Type: application/json'
curl -X POST localhost:8321/api/projects/census-2026/activate
Tools write into the active project (see Downloads / studio intro above):
quick_map and save_spatial_file place relative outputs under the active
project's maps/; the sidecar (<name>.json) still lands next to the artifact
and includes a "project" tag. Configure the projects root with
MAPCHALLENGE_PROJECTS_DIR (tests) — the default is
~/.local/share/mapchallenge/projects/.
Agent transparency feed
POST /api/agent/chat streams SSE frames
(data: {"type": "text|reasoning|tool_call|tool_result|chart|done|error", …}).
tool_result frames carry map_paths (absolute .png artifacts) and
geo_paths (absolute .geojson artifacts — the studio renders a View on map
link for them). Missing/misconfigured key → error event with HTTP 200, no
tracebacks.
Auto-charts (the chart convention)
The agent loop inspects every tool result by data shape, never by tool name.
When a shape it recognizes is found, it emits a structured chart event; the
studio renders the chart inline in the assistant message (Recharts, palette
matched). Two shapes are recognized:
-
Explicit spec — any tool may return a top-level
"chart"object in its envelope and it will be rendered verbatim (validated):{"status": "ok", "chart": {"chart_type": "bar | line | area", "title": "My title", "x": "<category field>", "y": "<numeric field>", "data": [{"col": "a", "n": 3}, {"col": "b", "n": 7}]}}
Rows are capped at 40; unknown
chart_typecoerces tobar; booleans/None are coerced. This is how future tools feed visuals (e.g. anh3_hexbinStatstool can return exactly this shape — no frontend or agent changes required). -
Numeric summary shape — a payload with a
"columns"mapping of{name: {"nulls": <number>, …}}(the shapedescribe_datasetreturns) yields abarchart, Missing values per column, automatically.
Wire event: {"type": "chart", "chart_type": "bar", "title": "…", "x": "…", "y": "…", "data": […]} — emitted after the tool_result frame, before done.
In the UI it appears inline below the assistant text. Inline ```chart fenced
blocks in markdown are also routed to the same renderer (reserved slot).
MCP client config (example)
{
"mcpServers": {
"mapchallenge": {
"command": "mapchallenge-mcp"
}
}
}
Smoke-test the stdio loop without a client UI:
python scripts/smoke_client.py # lists the tools
python scripts/smoke_client.py --map # also calls quick_map end-to-end
Conventions (all tools)
- File-based results — tools return absolute file paths, never raw geometries.
- Envelope — every tool returns
{"status": "ok", ...}or{"status": "error", "message": "<actionable hint>"}; tools never raise into the LLM. - Rounding — coordinates rounded to 5 dp; areas/lengths to 2 dp (km² / km).
- CRS guardrail — planar computations auto-project via
estimate_utm_crs()(manual UTM-zone fallback from the centroid). GeoJSON is always written EPSG:4326. - No cwd coupling — outputs and cache live under
~/.cache/mapchallenge/.
Extras (optional dependencies, not installed by default)
| Extra | Provides |
|---|---|
[web] |
The studio + HTTP mode: FastAPI + uvicorn (MCP streamable-http at /mcp) |
[overture] |
fetch_buildings — Overture Maps building footprints via DuckDB |
[osmnx] |
OSM geocoding/boundaries (Day 1+) |
[raster] |
density_hotspots, idw_surface, fetch_elevation + studio 3D terrain / viewpoint (rasterio) |
[h3] |
H3 hexagon indexing |
[gee] |
Google Earth Engine |
[basemap] |
contextily web basemaps in quick_map |
[boundaries] |
pycountry offline country lookup |
[dev] |
pytest, pytest-asyncio, ruff |
Security notes
- API keys live only in
~/.config/mapchallenge/config.toml(mode 0600), are masked in the UI and never appear in responses or logs. - The studio has no login.
--httpbinds to 127.0.0.1 by default; if you expose it on a network (--host 0.0.0.0), anyone who can reach it can use your configured keys. Keep it on trusted networks.
Data & attribution
OpenStreetMap data © OpenStreetMap contributors (ODbL). Buildings © Overture Maps Foundation (ODbL; includes OpenStreetMap, Google Open Buildings CC BY 4.0 and Microsoft ML Buildings). Elevation: Copernicus DEM GLO-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018, provided under COPERNICUS by the European Union and ESA (via AWS Open Data); SRTM via OpenTopoData. Weather © Open-Meteo (CC BY 4.0); climate from NASA POWER. Tools record the attribution in every file they write.
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md.
License
MIT
Metadata
Release files for mapchallenge-mcp 0.8.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 | Provenance | Actions |
|---|---|---|---|---|
| mapchallenge_mcp-0.8.0.tar.gz | 1.6 MB | Without provenance | Details for mapchallenge_mcp-0.8.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| mapchallenge_mcp-0.8.0-py3-none-any.whl | Python 3 | none | any | Details for mapchallenge_mcp-0.8.0-py3-none-any.whl |
Total release size: 3.0 MB
Release files / mapchallenge_mcp-0.8.0.tar.gz
| Download URL | mapchallenge_mcp-0.8.0.tar.gz |
|---|---|
| Size | 1.6 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cc9dc4cea19fbe71ee5c380e946911e16a14f381a16c3b43b76310f00a59fc85
|
|
BLAKE2b-256 checksum How to use checksums |
0408e678eae0fe19ee10d75478e0a678b1975c4261b1a1a7de0f88d03bdcfeb2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / mapchallenge_mcp-0.8.0-py3-none-any.whl
| Download URL | mapchallenge_mcp-0.8.0-py3-none-any.whl |
|---|---|
| Size | 1.4 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f0d2e58d0bd3197b764337497ab2c3c1c18fd959d497f4f282f27fd9b67c6cb2
|
|
BLAKE2b-256 checksum How to use checksums |
8308e5b590984f45141bdaeecbad3bcd038f0a03a55c9e8d31cc3a66a98e77a7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Security for mapchallenge-mcp 0.8.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.