Skip to content
Conduit

mcp server

geolayer-mcp

Curated, read-only access to your geospatial data for AI assistants. MCP server built on FastAPI.

lucarossid3v/geolayer-mcpcommunitylistedunreviewed

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`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "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, 2026

GeoLayer MCP

CI

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

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

ToolAnswers 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 crs parameter (e.g. EPSG:2056). Distances and areas are computed in the source's projected metric_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_results can lower the server limit but not raise it. Larger results say so with truncated: true and the total. include_geometry: false keeps 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 pathOperation
GET /api/v1/layerslist_layers
GET /api/v1/layers/{layer}describe_layer
GET /api/v1/layers/{layer}/locatelocate_point
POST /api/v1/layers/{layer}/searchsearch_features
POST /api/v1/layers/{layer}/nearbyfind_nearby
POST /api/v1/layers/{layer}/intersectingfind_intersecting
POST /api/v1/measuremeasure
POST /api/v1/layers/{layer}/elevationget_elevation
POST /api/v1/layers/{layer}/profileelevation_profile
POST /api/v1/layers/{layer}/terrainterrain_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:

  • sources reads files behind two common interfaces, one for vector layers and one for rasters (read window by window with rasterio), and handles native CRSs.
  • core holds 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 or server, and a test enforces it.
  • config validates the YAML with Pydantic and turns it into core settings.
  • server is 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:

ADRDecisionTrade-off accepted
0008Pure core, thin MCP and REST adaptersA translation layer and some types defined twice
0009MCP tools written by hand for an LLM, not generated from RESTTwo interfaces to keep aligned (shared parameter types help)
0010Read-onlyData changes need a restart; editing stays in your GIS
0011API keys (hashed, constant-time) instead of OAuth2No single sign-on or per-layer permissions in v1
0012WGS84 in and out, metric CRS for computationsA reprojection per request
0002Layers loaded in memory with an STRtree indexMemory grows with the data; sized for files, not huge datasets
0005Structured errors, audit as SDK middleware, auth as ASGI middleware, stateless HTTPRelies on SDK middleware marked provisional
0014Elevation rasters read window by window, three terrain tools, no orthophotosSlopes 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).

↑↓ navigate openesc close