GeoQuery AI
by sadiq-1698
README.md
# GeoQuery AI — Conversational Spatial SQL Engine via MCP Server
Ask questions in plain English ("hospitals within 2km of downtown Seattle") and get
live PostGIS query results rendered as GeoJSON on a map. A Claude agent, embedded in
a Next.js app, translates the question into tool calls against a custom Node.js
MCP server (`@modelcontextprotocol/sdk`) that executes validated spatial SQL.
## Architecture
- **`apps/web`** — Next.js (App Router) chat UI + MapLibre GL JS map. Hosts the
Claude tool-use agent loop (`app/api/chat/route.ts`) that acts as an MCP client.
- **`services/mcp-server`** — Node MCP server exposing three tools: `spatial_buffer`,
`isochrone_query`, and `postgis_raw_sql`, backed by a read-only Postgres role.
- **`db`** — PostGIS schema (OSM POIs + Census block groups) and a city-agnostic
ingestion pipeline (flag-driven: `--region`, `--pbf`, `--tiger-shp`, `--acs-csv`).
## Status
The full pipeline is wired end to end and has been run for real against a
real database: chat input in `apps/web` → `/api/chat` → the Claude agent
loop → MCP tool calls against PostGIS → GeoJSON streamed back → rendered on
the map, with a toggle list to show/hide results from earlier turns and
popups on both point and polygon features. The demo city (`bellingham-wa`)
is ingested and verified (see [db/ingest/README.md](db/ingest/README.md));
what's left is the one piece that needs a paid `ANTHROPIC_API_KEY` this
project hasn't used yet (deliberately — see below). See
[apps/web/README.md](apps/web/README.md) and
[services/mcp-server/README.md](services/mcp-server/README.md) for how each
half is built, [DEMO.md](DEMO.md) for a scripted set of example prompts
against the real demo data, and [WRITEUP.md](WRITEUP.md) for the
architectural decisions and real bugs found worth highlighting
(the isochrone approximation tradeoff, MCP/Anthropic schema reuse, the
layered SQL security model, and what building this against real data
actually surfaced that mocks couldn't).
## Setup
### 1. Database
```bash
cp .env.example .env # fill in POSTGRES_PASSWORD, MCP_RO_PASSWORD, ANTHROPIC_API_KEY
docker compose up -d
docker compose exec postgis pg_isready -U geoquery_admin -d geoquery # wait for "accepting connections"
```
On a **fresh** volume, Docker auto-applies everything in `db/schema/` via
`docker-entrypoint-initdb.d` (extensions, tables, indexes, and the read-only
`geoquery_ro` role). If you're re-provisioning an existing database instead
(schema changes after the first run), apply them manually:
```bash
./db/migrate.sh
```
`migrate.sh` tracks applied migrations in a `schema_migrations` table, so
it's safe to run any time — it only applies files that haven't run yet.
> Re-running `docker compose up` does **not** re-apply `db/schema/` — those
> init scripts only fire on a brand-new volume. To wipe and start over:
> `docker compose down -v` (destroys all data). To apply new schema files
> without losing data, use `./db/migrate.sh` instead.
### 2. Data ingestion
City-agnostic and flag-driven — nothing about a specific city is hardcoded
in any ingestion script; see [`db/ingest/config/example-city.env`](db/ingest/config/example-city.env)
for where to download a PBF/TIGER shapefile/ACS CSV and what shape they need.
```bash
pip install -r db/ingest/requirements.txt # needed for census/join_acs.py
./db/ingest/ingest.sh \
--region seattle-wa \
--pbf ./data/seattle.osm.pbf \
--tiger-shp ./data/tl_2023_53_bg.shp \
--acs-csv ./data/seattle_acs.csv \
--db-url "$DATABASE_URL"
```
Requires `osm2pgsql` (OSM loading) and `ogr2ogr`/GDAL (TIGER shapefile
loading) installed locally. `--acs-csv` is optional — without it, block
groups load with geometry only and `NULL` demographic columns. Re-running
`ingest.sh` for the same `--region` is safe (each step deletes that region's
existing rows before re-inserting); re-running for a *different* `--region`
just adds more rows alongside it, so multiple cities can coexist.
### 3. Running the app
The MCP server (`services/mcp-server`) is feature-complete for this phase —
all three tools (`spatial_buffer`, `isochrone_query`, `postgis_raw_sql`) are
registered and can be built/run/tested standalone; see
[its README](services/mcp-server/README.md) for how to exercise them
manually with the MCP Inspector.
The Next.js app (`apps/web`) is now a real, working chat client — type a
question and it genuinely calls `/api/chat`, which runs the Claude agent
loop against the MCP server above:
```bash
npm run --workspace @geoquery/web dev # http://localhost:3000
```
This needs your own `ANTHROPIC_API_KEY` in `.env` and a database with data
in it (steps 1-2 above) to produce real answers — without those, you'll see
real error messages in the chat (an auth error, or the model telling you it
has no data) rather than a crash, which is itself part of what's been
verified.
## Verified vs. user-verified
Some parts of this project (Docker provisioning, real OSM/Census data ingestion,
live end-to-end chat queries) require a local machine with Docker and downloaded
data extracts, and can't be verified in a sandboxed dev environment.
- **Verified so far**: shell script syntax (`bash -n`) for all `.sh` files;
Python syntax (`py_compile`) for `join_acs.py`; SQL DDL reviewed against
PostGIS/Postgres documentation; `services/mcp-server` actually builds and
runs — confirmed fail-fast behavior on missing config, and used a real MCP
client to confirm all three tools' Zod schemas convert to JSON Schema
correctly over the wire and that DB-dependent calls fail cleanly as an
`isError` result against an unreachable Postgres rather than crashing.
`services/mcp-server` also has a real automated test suite now (48 tests,
`npm run --workspace services/mcp-server test`, no database needed —
see [its README](services/mcp-server/README.md#testing)): the
`postgis_raw_sql` SQL guard's 34 cases (stacked queries, comment-hidden
payloads, CTE-disguised writes, disallowed tables), and
`spatial_buffer`/`isochrone_query`'s query-construction and GeoJSON
assembly logic against a mocked database. `apps/web` is also verified with
a real headless browser (Playwright driving system Chrome), not just
typecheck/build/curl: the MapLibre map actually renders and fills its
pane, attribution text is correct, clicking a point feature opens a popup
with the right content while clicking a polygon doesn't, and (after
finding and fixing a Turbopack/maplibre-gl worker-loading issue along the
way — see `apps/web/README.md`) there are no console errors left besides
an unrelated, pre-existing `favicon.ico` 404. `lib/mcp-client.ts` and
`lib/anthropic-tools.ts` are verified against the real running MCP
server (not mocked): a real spawn + connect, a second `getMcpClient()`
call reusing the same client (confirmed by reference equality, not just
"it didn't crash"), all three tools' schemas correctly mapped to
Anthropic's shape, and a real `callTool()` round trip. `lib/agent-loop.ts`
is verified the same way, minus the one piece needing a paid API key
(none available in this sandbox): a minimal fake Anthropic client drives
the loop through a tool_use turn then an end_turn turn while the MCP side
is entirely real (actual server spawn, actual `callTool()`) — confirming
the tool_result block is correctly threaded back into the next API call
with the right `tool_use_id`/`is_error`/content, text deltas forward
through the event callbacks, and the final message history is exactly
what it should be. `app/api/chat/route.ts` is verified against a real dev
server: all four request-validation failure cases return the correct
400s, and a valid request returns the correct streaming headers and a
genuine `authentication_error` from Anthropic's real API (no key
available here) arrives as a clean `error` event rather than a crash —
which also proves the MCP server spawn and `listTools()` succeeded first,
since the loop only reaches the Anthropic call after that. Caught and
fixed a real bug along the way: a NodeNext-style `.js` relative import
habit carried over from `services/mcp-server` that typecheck accepted but
Turbopack couldn't resolve, surfacing only once the file was actually
built into a route. Finally, `ChatPanel` → `/api/chat` → `MapView` is
verified as one real pipeline in a real browser: since no Anthropic key
exists in this sandbox, the `/api/chat` network call itself was mocked
(Playwright route interception) with a realistic ndjson stream, which
still exercises all of the real client-side code (fetch, stream parsing,
history reconciliation, map wiring) — confirmed the user bubble appears
immediately, two assistant turns from one exchange render as separate
bubbles once the authoritative `done` history lands (not merged, even
though they're concatenated during live streaming), the mechanical
tool_result message never renders as a chat bubble, a second turn resends
the *full* prior history plus the new message (proving the stateless
client-owns-history design actually works across turns, not just once),
and a single-point GeoJSON result really does fly the map in and render
the marker. The layer-toggle list and the new polygon popups are also
verified in a real browser: clicking a polygon now shows a populated
popup with a kind-aware heading and formatted values (previously nothing
happened — an intentional change from Session 10's scope, not a
regression), and — checked deterministically via a throwaway page
exposing the map instance directly, rather than relying on real
tile-loading timing in screenshots — unchecking a layer removes exactly
that layer's source and rendered layer while a second, untouched layer is
unaffected, and re-checking restores it.
- **Verified for real (Session 17)**: `docker compose up` provisioning
PostGIS end to end on a real machine (including a real port-conflict
fix — `POSTGRES_HOST_PORT` — when 5432 was already taken by an existing
local Postgres). Real `osm2pgsql` 1.6.0 ingestion against the full
Washington state Geofabrik extract (49.6M nodes, 5.28M ways) — this
surfaced and fixed two real bugs the flex Lua API docs didn't make
obvious: there is no `object:as_point()`/`as_polygon()` method at all
(geometry auto-populates on `add_row()` only when the table's `ids.type`
matches the column's geometry type), and `osm2pgsql --create` drops and
recreates *any* table its Lua script defines, even a pre-provisioned one
with a different schema — fixed by routing all osm2pgsql writes through
disposable staging tables, merged into the real `osm_pois` schema via
SQL (negating way-derived ids to avoid colliding with node ids in the
shared primary key). Real `ogr2ogr` TIGER shapefile ingestion (5,311
Washington block groups) worked on the first try, confirming the
long-assumed field-name convention (`statefp`/`countyfp`/`tractce`/
`blkgrpce`/`geoid`). All three MCP tools (`spatial_buffer`,
`isochrone_query`, `postgis_raw_sql`) called for real against this
282k-row database via a scripted MCP client: correct GeoJSON, correct
distance ordering, the isochrone's approximation caveat present, and the
SQL guard rejecting a real `DELETE` attempt. `EXPLAIN ANALYZE` against
real data found and fixed a real indexing bug: `ST_DWithin(geom::geography,
...)` on `osm_pois` was *not* using the plain `geometry`-typed GIST index
(`osm_pois_geom_gix`) at all — it fell back to a parallel sequential scan
(~170ms over 282k rows) because the bounding-box check happens in
geography space, not geometry space. A functional GIST index on the
geography cast (`db/schema/005_osm_pois_geog_index.sql`) fixed it (~8ms,
confirmed via the real MCP tool call, not just raw SQL) — the opposite
of what an earlier, unverified code comment in `queries.ts` had assumed.
`census_block_groups`' `ST_Intersects` query needed no such fix — it
compares geometry to geometry and used `cbg_geom_gix` correctly from the
start (confirmed, not assumed).
- **Verified for real (Session 18)**: the project's actual demo city,
`bellingham-wa` (`DEFAULT_REGION` in `.env`), ingested via a real, full
run of the `ingest.sh` orchestrator itself (not just its sub-scripts
individually) — see [`db/ingest/README.md`](db/ingest/README.md) for the
exact commands and why it's derived from the already-ingested
Washington-state data rather than a separate city PBF (Geofabrik has no
city-level extracts; BBBike's don't include Bellingham). This real run
found and fixed a genuine bug: `census/load_tiger.sh`'s merge had no
`ON CONFLICT` handling (unlike `osm/load_osm.sh`'s), so the first time two
regions' real data actually overlapped in `geoid` space it crashed — and
because the `DELETE`/`INSERT` weren't transactional, the `DELETE` had
already committed, silently dropping that region's 64 existing rows
before the crash. Fixed with `ON CONFLICT (geoid) DO NOTHING` plus an
explicit `BEGIN`/`COMMIT` (and applied the same transactional wrap to
`load_osm.sh`'s merge for consistency). Re-running afterward correctly
restored all rows — confirmed by querying both tables.
- **Verified for real (Session 19)**: a documentation/polish pass across
`apps/web` found and fixed a real UI bug via a read-only audit agent, then
confirmed the fix in a real browser (Playwright, mocked `/api/chat`
route): an error arriving mid-turn (no `done` event ever sent) used to
silently discard whatever text had already streamed in, leaving only the
error banner. `ChatPanel.tsx`'s `failTurn` now persists it as a real
message first. Also added (and real-browser-verified) a `"Loading map…"`
overlay for the previously-blank pane while the basemap style/tiles are
still loading. See [apps/web/README.md](apps/web/README.md) for the full
env-var-validation audit (every `process.env` read, which ones fail fast
and which don't, and why `ANTHROPIC_API_KEY` is the deliberate exception).
- **Verified for real (Session 20)**: a final whole-codebase bug-hunt pass
(not a diff review — everything was already committed) found and fixed
four more real bugs: `lib/mcp-client.ts` getting permanently wedged after
one transient connection failure (`??=` doesn't reassign on a rejected,
non-null promise); `postgis_raw_sql`'s `LIMIT`-detection regex missing a
parameterized `LIMIT $n` (exactly what the tool's own description tells
callers to use), producing a guaranteed syntax error; the agent loop
having no iteration cap and silently returning a reply truncated at
`max_tokens` with no indication anything was cut off; and the client
silently losing a successful tool call's history whenever a *later*
round in the same turn failed, since `done` (the only event that syncs
history) was never sent on failure. See
[apps/web/README.md](apps/web/README.md#reliability-fixes-from-a-whole-codebase-bug-hunt-session-20)
and [services/mcp-server/README.md](services/mcp-server/README.md) for
details and how each was verified (real browser tests for the frontend
fixes, a new regression test for the SQL guard). Also added
[DEMO.md](DEMO.md) (a scripted prompt sequence grounded in real
`bellingham-wa` data) and [WRITEUP.md](WRITEUP.md) (portfolio notes on
the isochrone tradeoff, MCP/Anthropic schema reuse, and the layered SQL
security model).
- **Not verified — needs your machine**: real ACS demographics for
`bellingham-wa` — the Census Bureau's public API now requires a free API
key even for small anonymous requests (it didn't previously), so
`census/join_acs.py` is still only unit-logic-verified, never run against
a real export; both demo regions' block groups have `NULL`
population/median_income/housing_units (see
[`db/ingest/README.md`](db/ingest/README.md) for the exact command once a
key is available). And the one remaining gap now that every piece of the
pipeline individually works for real — an end-to-end run with an actual
`ANTHROPIC_API_KEY` and this real ingested data, asking a real question
and getting a real model-reasoned answer with real GeoJSON on the map.
Every piece of that chain has been verified in isolation (real MCP server
against real data, real Anthropic request shape and error handling, real
streaming/map wiring); what's never happened is all of them firing
together with real credentials.
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo issues