Skip to main content

mapchallenge-mcp

tests

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. via mcp.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 by quick_map also drop a <name>.json sidecar (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) and GET /api/projects/{name}/conversations/{cid} (detail). POSTing to /api/agent/chat with {"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 .geojson from 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}/zip for 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_file write into the active project's maps/ directory when one is set (cache outputs/ 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:

  1. 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_type coerces to bar; booleans/None are coerced. This is how future tools feed visuals (e.g. an h3_hexbinStats tool can return exactly this shape — no frontend or agent changes required).

  2. Numeric summary shape — a payload with a "columns" mapping of {name: {"nulls": <number>, …}} (the shape describe_dataset returns) yields a bar chart, 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. --http binds 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)

Source distribution for mapchallenge-mcp 0.8.0
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)

Table of built distributions (wheels) for mapchallenge-mcp 0.8.0
File Interpreter ABI Platform Actions
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.

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page