Geospatial Atlas ships a Model Context Protocol server so that any MCP-capable LLM (Claude Desktop, Claude Code, Cursor, Continue, your own SDK client, …) can drive the viewer in real time: run SQL against your data, list/add/update/delete charts, change column styles, capture screenshots.
The transport is Streamable HTTP — no bridge scripts, no Node.js, no stdio shim.
Via the Python CLI:
uv run geospatial-atlas /path/to/your.parquet --mcpVia the desktop app: MCP is enabled by default. When you load a dataset, the idle-state launch form has an "Expose MCP endpoint" checkbox; the port is picked at launch time and shown (with a copy button) in the status panel. Uncheck to run without MCP. The preference is per-launch — closing the app returns to the default on/off you last set.
The URL banner printed on launch is where the viewer and MCP clients connect:
➜ URL: http://localhost:5055
➜ MCP server: http://localhost:5055/mcp
http://localhost:5055
The viewer is where the tool handlers actually execute (chart state, Mosaic coordinator, canvases all live in the webview). Keep the tab open while you're chatting with the LLM.
Two supported paths:
-
Interactive — real Chrome/Safari tab. What you want ~99 % of the time: full WebGPU, fast tile loading, no headless quirks. Just open the URL in your normal browser and leave the tab open.
-
Headless — Playwright chromium. Useful for autonomous runs, CI, or any time you don't want a visible window. Script:
node scripts/mcp_harness/viewer_holder.mjs http://localhost:5055
The harness prints
VIEWER READYwhen the map hook is live and stays up until killed. Seescripts/mcp_harness/mcp.shfor a companion curl wrapper (list,call,sql,raw).
Either path exposes the exact same tool set to the MCP endpoint — clients can't tell which one is driving.
Claude Desktop — edit ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"geospatial-atlas": {
"url": "http://localhost:5055/mcp"
}
}
}Fully quit Claude Desktop (⌘Q) and reopen. You should see 19 tools show up in the tool-picker.
Claude Code / Cursor / Continue / any other client: same — they all accept a URL entry for MCP servers. Check their docs for the exact config path.
Programmatic / raw — any HTTP client:
curl -s -X POST http://localhost:5055/mcp/ \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
| grep '^data: ' | head -1 | cut -c7-That returns the live tools list as JSON.
get_data_schema— table name + column list (names, types)run_sql_query— readonly SQL against the loaded DuckDB
list_charts,add_chart,delete_chartget_chart_spec,set_chart_specget_chart_state,set_chart_state,clear_chart_stateget_chart_screenshot— PNG of one chart
get_layout_type,set_layout_type("list"/"dashboard")get_layout_state,set_layout_stateget_full_screenshot— PNG of the entire viewer
list_renderers— column renderer types (text, number, timestamp, bar, …)get_column_styles,set_column_style
The geo tools operate on whichever embedding chart has data.isGis = true
— they locate it by scanning charts and drive the MapLibre basemap
- scatter overlay in lockstep by setting chart state. Screenshots only
capture the rendered basemap after calling
map.once('idle'), so tiles are guaranteed loaded before the PNG is produced.
get_map_viewport— current center (lon/lat), MapLibre zoom, bbox (west/south/east/north), canvas size in CSS pixels, and the chart id. Always the first tool to call when doing anything geographic.fly_to_point—{lon, lat, zoom?}; zoom defaults to 10, clamped to[1, 19]. UsesjumpTointernally so it's instantaneous.fly_to_bbox— fit a{west, south, east, north, padding?}region. Honours the viewport aspect ratio;paddingis a fraction of the smaller side.get_map_screenshot— PNG of just the map canvas (scatter overlay + basemap + markers), without the sidebar / charts / etc.get_map_screenshot_at— composite: set viewport (either{lon, lat, zoom?}or{west, south, east, north, padding?}), wait for the map to become idle, then screenshot. Accepts an optionalsettle_msthat adds fixed wait after tile idle (default 0).select_bbox— cross-filter the whole viewer to rows whose(lon, lat)falls in the bbox; sets the GIS chart'sbrush. Returns{applied, brush, matched_count}wherematched_countis a SQLCOUNT(*)inside the bbox (not the downsampled render count).clear_selection— clear the brush.count_in_bbox— read-only: just the row count inside a bbox. Does not touch the brush. Use this when you're probing many candidate regions.find_nearby—{lon, lat, radius_km?, limit?, columns?, where?}. Returns the nearest rows with great-circle distance. Pre-filters with a degree-sized bbox so it's fast even on 75 M rows; requires an ad-hoc SQLwherefragment for extra filtering.density_grid—{west, south, east, north, nx?, ny?, top_k?}— bucket rows into annx × nygrid and return per-cell counts. Great for finding density outliers inside a specific region.highlight_points— draw temporary circular markers (with optional colour + label) at{points: [{lon, lat, label?, color?, radius?}]}. Appears on the nextget_map_screenshot. Pass{points: []}to clear.set_basemap_style—{style}accepts a MapLibre style URL or one of the built-in keys:"openfreemap-liberty"(default),"openfreemap-positron","openfreemap-bright","osm-raster","none".
Viewport mechanics. The GIS embedding chart stores its viewport as
{x, y, scale} where x = lon, y = projectLat(lat), and the derived
MapLibre zoom is log2(360 · scale · canvas_width / 1024). The geo
tools translate to and from lat/lon + zoom automatically so clients
shouldn't need to think about it. If for some reason you want to set
viewport manually, set_chart_state with the GIS chart id still works.
Claude Desktop / Cursor / etc. ─── Streamable HTTP ──▶ /mcp
│
│ JSON-RPC forward
▼
WebSocketHandler
│
│ ws://…/data/mcp_websocket
▼
Viewer JavaScript
│
│ executes
▼
DuckDB / charts / canvas
The Python side (packages/backend/embedding_atlas/mcp_bridge.py) is a pure
protocol adapter. The tool implementations themselves live in the viewer
(packages/viewer/src/model_context/model_context.ts) — a tool added there
becomes available to MCP clients automatically with zero Python changes.
- Viewer browser tab must be open. The tools run in the webview — close the tab and
tools/callreturns an error. Ontools/list, a warning logs and the cached tool list (if any) is returned. - One active viewer per server. Opening a second tab disconnects the first. The last-connected viewer owns the MCP session.
- Read-only SQL enforced server-side. The backend DuckDB connection has
enable_external_access = falseandlock_configuration = true. Attempts to mutate data will error out, not succeed silently. - MCP needs to be opted in. The CLI ships it as
--mcp(default off). The native desktop app ships it default on, with a checkbox on the dataset picker to disable per-launch; once a dataset is loaded the port is shown and the full/mcpURL can be copied directly from the status panel.
I just loaded an Overture Places parquet file. Use the geospatial-atlas tools to:
- Describe the schema.
- Count rows per
primary_categoryand show me the top 10.- Add a density plot coloured by category.
- Switch the layout to dashboard and take a screenshot.
Claude will chain get_data_schema → run_sql_query → add_chart (density spec)
→ set_column_style → set_layout_type → get_full_screenshot. You'll see each
step happen live in the browser.
The pre-standard POST /mcp endpoint (simple JSON-RPC forwarder, no session, no SSE)
is still available at /mcp_legacy for backwards compatibility. New integrations
should use the standard /mcp endpoint.