Metadata-Version: 2.5
Name: mapchallenge-mcp
Version: 0.8.0
Summary: LLM-friendly GIS tools over MCP, plus a web studio to chat with a GIS agent and see results on a map
Project-URL: Homepage, https://github.com/jydestudio/mapchallenge-mcp
Project-URL: Issues, https://github.com/jydestudio/mapchallenge-mcp/issues
Project-URL: Changelog, https://github.com/jydestudio/mapchallenge-mcp/blob/main/CHANGELOG.md
Author: jydestudio
License: MIT
License-File: LICENSE
Keywords: 30daymapchallenge,agent,geospatial,gis,llm,maplibre,mcp,openstreetmap,overture
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.11
Requires-Dist: geopandas>=1.0
Requires-Dist: matplotlib>=3.8
Requires-Dist: mcp<2,>=1.9
Requires-Dist: openai>=1.40
Requires-Dist: pyproj>=3.6
Requires-Dist: shapely>=2.0
Provides-Extra: basemap
Requires-Dist: contextily>=1.4; extra == 'basemap'
Provides-Extra: boundaries
Requires-Dist: pycountry>=24.6; extra == 'boundaries'
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: gee
Requires-Dist: earthengine-api>=0.1; extra == 'gee'
Provides-Extra: h3
Requires-Dist: h3>=4.0; extra == 'h3'
Provides-Extra: osmnx
Requires-Dist: osmnx>=1.9; extra == 'osmnx'
Provides-Extra: overture
Requires-Dist: duckdb>=1.1; extra == 'overture'
Provides-Extra: raster
Requires-Dist: rasterio>=1.3; extra == 'raster'
Provides-Extra: web
Requires-Dist: fastapi>=0.110; extra == 'web'
Requires-Dist: uvicorn>=0.29; extra == 'web'
Description-Content-Type: text/markdown

# mapchallenge-mcp

[![tests](https://github.com/jydestudio/mapchallenge-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/jydestudio/mapchallenge-mcp/actions/workflows/test.yml)

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**.

```bash
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:

```bash
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

```bash
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.

```bash
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. |

```python
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 |

```bash
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):

```bash
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:

```bash
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)

```bash
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:

```bash
# 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:

```bash
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):

   ```json
   {"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)

```json
{
  "mcpServers": {
    "mapchallenge": {
      "command": "mapchallenge-mcp"
    }
  }
}
```

Smoke-test the stdio loop without a client UI:

```bash
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](CONTRIBUTING.md).

## License

MIT
