mcp server
geolayer-mcp
Curated, read-only access to your geospatial data for AI assistants. MCP server built on FastAPI.
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
{
"mcpServers": {
"geolayer-mcp": {
"command": "uvx",
"args": [
"geolayer-mcp"
]
}
}
}same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Oct 25, 2026GeoLayer MCP
An open-source MCP server, built on FastAPI, that gives AI assistants curated, read-only access to geospatial layers (GeoPackage, GeoJSON and raster elevation models) through ten predefined geographic operations.
You decide which layers and attributes the assistant can see. It answers questions such as "In which municipality is Monte Tamaro?" by running real spatial operations on your data. It does not guess, and every answer says which data and parameters produced it.
Status: v0.2, alpha. Seven vector tools and three elevation tools, stdio and HTTP transports, API keys, an audit log and an optional REST API work, and a complete demo on Swiss open data (municipalities, places and a 2 m terrain model) is included. It is not yet published on PyPI: install it from a clone of this repository.
Contents
- The problem
- Principles
- Tools
- Quick start: the Ticino demo
- Connect an MCP client
- Run with Docker
- Configuration
- REST API
- Architecture
- Design decisions and trade-offs
- What it does not do
- Development
- License
The problem
Simple geographic questions ("what is within 500 m of this address?", "which zone does this point fall in?") often still need someone who can use a GIS or write a spatial query. AI assistants could answer them, but on their own they do not know your data, and when pushed they invent coordinates and results.
Connecting an assistant straight to a database or a GIS library does not solve it either: whoever owns the data needs to control what the assistant sees and does, and to check afterwards what it asked.
Principles
GeoLayer MCP is about curated, governed access to your own layers:
- Curated. The data owner lists the layers the assistant can see (hence the name), and which attributes are exposed or hidden, for example owners' personal data.
- Predefined operations only. Ten tools with descriptions written for an LLM. No free-form queries, no code execution, no writes.
- Traceable. Every call is written to an audit log with the API key that made it, and every answer carries its provenance: operation, source, parameters, attribution.
- Just files. A YAML file and a GeoPackage, GeoJSON or GeoTIFF are enough. No database, no GIS server.
Tools
| Tool | Answers questions like |
|---|---|
list_layers | "What data can I ask about?" |
describe_layer | "Which attributes does this layer have? What do the values look like?" |
search_features | "Find the places whose name contains 'Ronco'" (by name or attribute) |
locate_point | "In which municipality is this point?" |
find_nearby | "Which places are within 2 km of Ascona?" |
find_intersecting | "Which municipalities does the river cross?" (buffer_m for "within 50 m of this road") |
measure | "How large is Lugano? How far is Bellinzona from Locarno?" |
get_elevation | "At what elevation is Ascona? Which way does this slope face?" |
elevation_profile | "How much do I climb along this road? What is its steepest grade?" |
terrain_stats | "How high does Gambarogno reach? How much of it is steeper than 30°?" |
The last three work on raster elevation layers (a digital terrain model such as swissALTI3D), taking points, lines and polygons from the vector layers as references. Rasters are never loaded whole: each call reads only the cells it needs, and large areas are read at a coarser resolution that the answer reports (ADR 0014).
Conventions shared by all tools:
-
Coordinates: input in WGS84 (lon, lat) by default, or in any CRS through the optional
crsparameter (e.g.EPSG:2056). Distances and areas are computed in the source's projectedmetric_crs, never in degrees. Results are GeoJSON in WGS84. -
References: every feature has a stable
layer+id, so the result of one call can be the input of the next (feature_layer,feature_id). Composite questions are answered with several calls. -
Limits:
max_resultscan lower the server limit but not raise it. Larger results say so withtruncated: trueand thetotal.include_geometry: falsekeeps responses small. -
Provenance: each result lists the operation, its parameters and the sources with their attribution.
-
Errors have a stable code, a message and a hint the assistant can act on. No stack traces or file paths ever reach the client:
{ "code": "LAYER_NOT_FOUND", "message": "There is no layer named 'comuni'.", "hint": "Available layers: canton, districts, municipalities, places, lines, areas. Call `list_layers` to see them with descriptions." }
Quick start: the Ticino demo
The demo serves the municipalities, districts and place names of Canton Ticino, Switzerland, from swisstopo open data. You need uv (it installs Python 3.14 if needed) and about 1 GB of free disk space.
git clone https://github.com/lucarossid3v/geolayer-mcp.git
cd geolayer-mcp
uv sync
uv run python examples/ticino-demo/prepare_data.py
uv run geolayer-mcp validate-config examples/ticino-demo/geolayer.yaml
prepare_data.py downloads about 500 MB from swisstopo once and builds a 20 MB
GeoPackage in a few seconds. It then downloads about 250 MB of 2 m elevation tiles for
the area around Locarno, Ascona and the Gambarogno, and merges them into one GeoTIFF
(skip this with --no-dem). Then connect a client (next section) and ask:
- In which municipality is Monte Tamaro?
- Which places are within 2 km of Ascona?
- How large is the municipality of Lugano? Does it match the official figure?
- How high does the municipality of Gambarogno reach, and how much of it is steeper than 30°?
More questions, and how the data is prepared, are in examples/ticino-demo/README.md. No Python on your machine? See Run with Docker.
Connect an MCP client
Claude Desktop (stdio)
In claude_desktop_config.json (Settings → Developer → Edit Config), with the absolute
path of your clone:
{
"mcpServers": {
"geolayer-ticino": {
"command": "uv",
"args": [
"run", "--directory", "/path/to/geolayer-mcp",
"geolayer-mcp", "serve",
"--config", "examples/ticino-demo/geolayer.yaml",
"--transport", "stdio"
]
}
}
}
Restart Claude Desktop. In stdio mode no key is needed: the server is a process of your client, on your machine, and the audit log goes to standard error.
Claude Code (stdio)
claude mcp add geolayer-ticino -- uv run --directory "$PWD" geolayer-mcp serve \
--config examples/ticino-demo/geolayer.yaml --transport stdio
Any client over HTTP
HTTP mode is for a shared service and requires at least one API key:
uv run geolayer-mcp generate-key my-client # prints the key once, and the entry to paste
# paste the entry under auth.api_keys in the configuration, then:
uv run geolayer-mcp serve --config examples/ticino-demo/geolayer.yaml --transport http
Clients connect with Streamable HTTP to http://127.0.0.1:8000/mcp, sending
Authorization: Bearer <key>. GET /health needs no key. Use --host and --port to
change the bind address, and put a reverse proxy with TLS in front of it in production.
Run with Docker
The image runs HTTP mode and reads /config/geolayer.yaml. docker-compose.yml serves
the Ticino demo:
docker compose build
docker compose run --rm prepare # builds the demo data, once
docker compose run --rm geolayer generate-key demo # paste the entry under auth.api_keys
# in examples/ticino-demo/geolayer.yaml
docker compose up -d
curl http://localhost:8000/health # {"status":"ok"}
MCP is then on http://localhost:8000/mcp. For your own data, mount a directory holding
your configuration and files:
docker run -p 8000:8000 -v "$PWD/my-geodata:/config:ro" geolayer-mcp
Configuration
One YAML file declares the sources, the layers the assistant may see, and security. Relative paths are resolved against the file's directory.
max_results: 100 # upper limit for every tool call
max_raster_cells: 4000000 # cells read per terrain call before coarsening
sources:
- name: cadastre
type: geopackage # geopackage | geojson | raster
path: data/cadastre.gpkg
metric_crs: EPSG:2056 # projected CRS in metres, used for distances and areas
attribution: "© My Organisation"
- name: terrain
type: raster # one band of elevations: GeoTIFF, COG, VRT...
path: data/dtm.tif # projected in metres, north-up
metric_crs: EPSG:2056
layers:
- name: parcels # the name the assistant sees
source: cadastre
source_layer: parcels_2026 # layer inside the GeoPackage (optional)
description: "Land parcels. zone is the zoning code, e.g. R2 = residential."
hidden_attributes: [owner_name] # or `attributes: [...]` to list the visible ones
name_attribute: parcel_number # enables text search by name
- name: elevation # a raster layer: no attributes, used by terrain tools
source: terrain
description: "Ground elevation in metres, 2 m cells, municipality area only."
auth:
api_keys: # HTTP mode only; from `geolayer-mcp generate-key`
- id: gis-team
hash: "sha256:…"
audit:
destination: file # stderr (default) | file
path: logs/audit.jsonl
rest:
enabled: false # optional REST API under /api/v1
Unknown keys are rejected, so a typo cannot silently expose a hidden attribute.
geolayer-mcp validate-config <file> checks the file and opens the data: layers,
attributes and CRS must exist. The server does the same at startup. Details in
ADR 0004.
REST API
With rest.enabled: true, HTTP mode also exposes the ten operations as a REST API
under /api/v1, with the same keys and the same audit log. Its OpenAPI documentation
is written for humans and served publicly at /docs and /redoc.
curl -H "Authorization: Bearer $KEY" \
"http://127.0.0.1:8000/api/v1/layers/municipalities/locate?lon=8.9511&lat=46.0037&include_geometry=false"
| Method and path | Operation |
|---|---|
GET /api/v1/layers | list_layers |
GET /api/v1/layers/{layer} | describe_layer |
GET /api/v1/layers/{layer}/locate | locate_point |
POST /api/v1/layers/{layer}/search | search_features |
POST /api/v1/layers/{layer}/nearby | find_nearby |
POST /api/v1/layers/{layer}/intersecting | find_intersecting |
POST /api/v1/measure | measure |
POST /api/v1/layers/{layer}/elevation | get_elevation |
POST /api/v1/layers/{layer}/profile | elevation_profile |
POST /api/v1/layers/{layer}/terrain | terrain_stats |
Architecture
flowchart LR
client["MCP client<br/>(Claude, …)"] -- "stdio or<br/>Streamable HTTP" --> mcp
app["HTTP client"] -- "REST" --> rest
subgraph server ["server (FastAPI)"]
auth["API key middleware"] --> mcp["MCP adapter<br/>10 hand-written tools"]
auth --> rest["REST adapter<br/>(optional)"]
audit["audit log"]
end
mcp --> core
rest --> core
config["config<br/>YAML → settings"] -.-> core
core["core<br/>catalog + 10 operations"] --> sources["sources<br/>GeoPackage, GeoJSON, raster"]
Four units under src/geolayer_mcp/, with dependencies in one direction only:
sourcesreads files behind two common interfaces, one for vector layers and one for rasters (read window by window with rasterio), and handles native CRSs.coreholds the catalog (vector layers loaded in memory with a spatial index, raster layers read on demand) and the ten operations, computed with Shapely and numpy. It never imports FastAPI, the MCP SDK orserver, and a test enforces it.configvalidates the YAML with Pydantic and turns it intocoresettings.serveris the FastAPI app: the MCP adapter (official Python SDK, mounted in FastAPI), the optional REST adapter, API key authentication and the audit log, shared by both adapters.
A request goes through authentication (HTTP only), parameter validation by the adapter,
the operation in core (limits and attribute visibility applied there), then the
adapter adds provenance and the audit log records the key, tool, layers, parameters,
result count, duration and outcome. stdio and HTTP run the same code.
Design decisions and trade-offs
Each choice is recorded as a short ADR in docs/decisions/ (in
Italian, the author's language). The main ones:
| ADR | Decision | Trade-off accepted |
|---|---|---|
| 0008 | Pure core, thin MCP and REST adapters | A translation layer and some types defined twice |
| 0009 | MCP tools written by hand for an LLM, not generated from REST | Two interfaces to keep aligned (shared parameter types help) |
| 0010 | Read-only | Data changes need a restart; editing stays in your GIS |
| 0011 | API keys (hashed, constant-time) instead of OAuth2 | No single sign-on or per-layer permissions in v1 |
| 0012 | WGS84 in and out, metric CRS for computations | A reprojection per request |
| 0002 | Layers loaded in memory with an STRtree index | Memory grows with the data; sized for files, not huge datasets |
| 0005 | Structured errors, audit as SDK middleware, auth as ASGI middleware, stateless HTTP | Relies on SDK middleware marked provisional |
| 0014 | Elevation rasters read window by window, three terrain tools, no orthophotos | Slopes depend on the resolution; large areas are coarsened |
Also: 0001 MCP SDK version and transport, 0003 measuring free geometries, 0004 configuration format, 0006 REST adapter, 0007 demo data preparation.
What it does not do
Out of scope for v1, by design:
- modify data;
- read PostGIS or OGC services (WFS, OGC API): files only, for now;
- OAuth2/OIDC, per-layer or per-user permissions, rate limiting;
- combine several operations in one tool call (the assistant chains calls instead);
- draw maps (results are GeoJSON your tools can display);
- cache queries;
- routing or other complex analysis; of raster data, only elevation models are supported (no orthophotos or generic rasters, see ADR 0014).
Development
uv sync # environment from uv.lock (Python 3.14)
uv run pytest # all tests, e2e included (no network needed)
uv run pytest tests/core tests/sources # the geospatial core only
uv run ruff check && uv run ruff format --check
uv run mypy src # strict
The end-to-end tests run the real command over stdio in a subprocess and a real uvicorn
server over HTTP, and replay the Ticino demo's sample questions on a small extract
committed in tests/fixtures/.
GitHub Actions runs the same checks on every push and pull request, with the tests on Python 3.12, 3.13 and 3.14.
License
Apache-2.0. See LICENSE and NOTICE.
The full Ticino demo data is not part of this repository: it is downloaded from swisstopo.
The small extracts under tests/fixtures/ (used by the tests) are derived from it.
All of it is © swisstopo, open data with attribution
(terms of use).