Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/publish-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ jobs:

# Tests cover the pure-Python builders and do not need the bundled web
# app, so install only the runtime/test deps and run against the sources.
# `mcp` is an optional extra, but tests/test_mcp_server.py skips itself
# without it, so install it here or the MCP server ships untested.
- name: Install test dependencies
run: python -m pip install anywidget traitlets pytest
run: python -m pip install anywidget traitlets pytest "mcp>=2.0"

- name: Run tests
run: python -m pytest python/tests
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ Rendering is MapLibre GL JS in the webview, with **deck.gl** for raster/point-cl

The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); confined to `GEOLIBRE_CONVERSION_ROOTS` (default `/data`). Local MBTiles use a custom MapLibre protocol backed by Tauri commands.

**MCP server** (`python/src/geolibre/mcp/`, the `geolibre-mcp` console script): a headless stdio MCP server that authors `.geolibre.json` files. It is layered so nothing duplicates: `project.py` *builds* pieces (a layer, a plugin-state blob), `authoring.py` *applies* them to a whole project (add/remove/restyle a layer, move the camera, compose the legend/colorbar/swipe controls), and both `Map` and the MCP tools delegate to `authoring.py` — so a change to how a control is composed lands in one place. `server.py` is the only module that imports the `mcp` SDK (optional extra `geolibre[mcp]`), and `workspace.py` confines every path to `GEOLIBRE_MCP_ROOTS`/`--root` the way the sidecar confines to `GEOLIBRE_CONVERSION_ROOTS`. `python/tests/test_mcp_server.py` skips itself without the SDK, so `publish-python.yml` installs `mcp` explicitly — drop it and the server ships untested.

## Conventions

- Never commit directly to `main`; branch and open a PR.
Expand All @@ -97,4 +99,4 @@ The browser build proxies the sidecar at `/sidecar` (same-origin, no CORS); conf
- `propertySpecFor` (`packages/core/src/expressions.ts`) fabricates the **unexported** `StylePropertySpecification` shape that `@maplibre/maplibre-gl-style-spec`'s `createExpression` uses for expected-result-type enforcement (the Expression Builder's filter → boolean / color checks). The cast hides any contract change from the compiler, so whenever `@maplibre/maplibre-gl-style-spec` is bumped (including Dependabot PRs) run the frontend suite — the "enforces an expected result type" test in `tests/expressions.test.ts` fails if the shape stops being honored.
- `DISTANCE_SEGMENTS` / `NON_DISTANCE_NAMES` (`apps/geolibre-desktop/src/lib/whitebox-distance-params.ts`) decide, by parameter *name*, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (`tolerance`, `radius`, `length`, `resolution`), so a tool can carry a matching name that is not a length — `corridor_tolerance` is a 0-1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. So whenever `geolibre-wasm` is bumped (in `packages/processing/package.json`) — including Dependabot PRs — scan the new catalog for a `double` matching the rule whose description reads as a fraction, ratio, angle or weight, and add it to `NON_DISTANCE_NAMES`. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance, with no build error.
- UI strings are translatable via **react-i18next**; catalogs live in `apps/geolibre-desktop/src/i18n/locales/*.json` (`en.json` is the source of truth, typed by `i18next.d.ts`). Use `t()` for new user-facing strings; a `?locale`/`?lang` query param sets the embed language. The UI mirrors for right-to-left locales (Arabic), so style new components with Tailwind's logical utilities (`ms-`/`me-`/`ps-`/`pe-`/`text-start`/`border-s`/`start-`…), not the physical `ml-`/`left-` forms. See `docs/i18n.md`.
- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/i18n.md`, `docs/contributing.md`.
- Reference docs: `docs/architecture.md`, `docs/project-format.md`, `docs/plugin-api.md`, `docs/python.md`, `docs/mcp.md`, `docs/i18n.md`, `docs/contributing.md`.
161 changes: 161 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# MCP server

GeoLibre ships an [MCP](https://modelcontextprotocol.io) server that authors
GeoLibre projects. Point an MCP client (Claude Desktop, Claude Code, or any
other) at it and you can ask for a map in words: the server writes a real
`.geolibre.json` project you open in the desktop app, the web app, or the
`geolibre` Jupyter widget, and can export it as a standalone HTML page.

The server is **headless**. It needs no browser, no running GeoLibre instance,
and no bundled web build. It builds project files with the same
[project builders](python.md) the Python package uses, so a project it writes is
byte-for-byte the kind the app already loads.

## Install

The MCP SDK is an optional extra:

```bash
pip install "geolibre[mcp]"
```

## Run it

```bash
geolibre-mcp --root ~/maps
```

The server speaks MCP over stdio, which is what desktop clients spawn. The
`--root` flag is repeatable, and `GEOLIBRE_MCP_ROOTS` (`:`-separated, `;` on
Windows) does the same job from the environment. With neither set, the workspace
is the current directory.

### Client configuration

Claude Desktop (`claude_desktop_config.json`) and most other clients take the
same shape:

```json
{
"mcpServers": {
"geolibre": {
"command": "geolibre-mcp",
"args": ["--root", "/Users/you/maps"]
}
}
}
```

For Claude Code:

```bash
claude mcp add geolibre -- geolibre-mcp --root ~/maps
```

If `geolibre-mcp` is not on the client's `PATH` (common when it was installed
into a virtualenv), give the interpreter instead:

```json
{
"mcpServers": {
"geolibre": {
"command": "/path/to/venv/bin/python",
"args": ["-m", "geolibre.mcp", "--root", "/Users/you/maps"]
}
}
}
```

## The workspace

Every path in every tool call is resolved against the allowed roots before the
server touches it, mirroring `GEOLIBRE_CONVERSION_ROOTS` in the
[sidecar](server-api.md). Paths outside them are refused, and so is a symlink
inside a root that points out of it. Relative paths resolve against the first
root, so a client can say `city.geolibre.json` without knowing the host layout.

Three more guards on writes: the server only writes files ending in `.json`
(projects) or `.html` (exports) — a bare `.json` with no name is refused too —
it refuses to replace an existing file unless the call passes `overwrite`, and
a tool that edits an existing project first checks the file actually is one, so
an unrelated `package.json` sitting inside a root cannot be rewritten as a map.

Give it a directory meant for maps, not your home directory.

## Tools

### Project lifecycle

| Tool | What it does |
| --- | --- |
| `create_project` | Write a new, empty project with a name, center, zoom, and basemap. |
| `describe_project` | Summarize the camera, basemap, layers, and map controls. Inlined feature data is reported as a count, never echoed back. |
| `list_catalog` | List the named basemaps, color ramps, and legend presets, plus the active workspace roots. |

### Adding layers

| Tool | For |
| --- | --- |
| `add_geojson_layer` | Vector data inlined into the project, from a URL, a workspace file, or literal GeoJSON. Self-contained, and the only kind `classify_layer` can style. |
| `add_vector_layer` | A large remote FlatGeobuf / GeoParquet / GeoJSON read in place. |
| `add_raster_layer` | A Cloud Optimized GeoTIFF, with band, colormap, and rescale options. |
| `add_tile_layer` | A raster XYZ tile template. |
| `add_tiles_layer` | PMTiles archives and vector tile services. |
| `add_ogc_layer` | WMS and WMTS endpoints. |
| `add_3d_tiles_layer` | OGC 3D Tiles tilesets. |

### Editing

| Tool | What it does |
| --- | --- |
| `update_layer` | Rename, show/hide, set opacity, or reorder. |
| `remove_layer` | Drop a layer. |
| `style_layer` | Merge style keys (`fillColor`, `strokeWidth`, `circleRadius`, …). |
| `classify_layer` | Build a graduated choropleth from a numeric column. |
| `list_layer_properties` | List a layer's feature properties with sample values. |

Layers are addressed by id **or** by display name, so a client can work from
what `describe_project` showed it without tracking UUIDs.

### Framing and decoration

| Tool | What it does |
| --- | --- |
| `set_view` | Set center, zoom, bearing, and pitch, or pass a `bbox` to frame an area. |
| `set_basemap` | Switch the background style. |
| `add_legend` | Add a legend from a preset, a `{label: color}` map, or paired lists. |
| `add_colorbar` | Add a colorbar for continuous data. |
| `add_swipe` | Configure the split-map comparison slider. |

### Export

`export_html` writes a standalone page that embeds the hosted GeoLibre viewer
and injects the project into it, so the recipient needs no install. Credentials
are stripped from the project on the way out. Layers pointing at local files
will not load for anyone else, so use hosted URLs for a shareable export.

## Notes and limits

- **`set_view` with a `bbox` is approximate.** A saved project stores a center
and zoom, and the app applies those verbatim on load rather than fitting a
stored bbox. The server therefore resolves the box to a camera itself, using
an assumed map-pane size, and lands within roughly half a zoom level of what
the app's own "zoom to layer" would pick. Pass `center` and `zoom` when you
need exact framing.
- **Inlined GeoJSON is capped at 50 MB**, and a project file the server reads at
256 MB. Past those, use `add_vector_layer` or a tiled source.
- **Remote fetches are checked**: a URL whose host resolves to a private,
loopback, or link-local address is refused, on every redirect hop as well as
the first request, so a crafted URL cannot reach a cloud metadata endpoint.
- The server authors projects; it does **not** drive a live map. Interactive
control of a running GeoLibre instance goes through the scripting bridge that
backs the [Python widget](python.md) and the
[embed API](user-guide/embedding.md).

## Under the hood

The tools are thin wrappers over `geolibre.authoring`, a widget-free module of
operations on project dicts (add/remove/restyle a layer, move the camera,
compose the map controls). `geolibre.Map` delegates to the same module, so the
notebook widget and the MCP server cannot drift apart in how they build a
project.
15 changes: 15 additions & 0 deletions docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,21 @@ UI edits flow back the same way.
instead, so those can still point at a local server. Do not load untrusted
`.geolibre.json` projects or URLs on a shared/multi-tenant kernel.

## MCP server

The same package ships an [MCP](https://modelcontextprotocol.io) server that
authors `.geolibre.json` projects from an AI client, with no notebook and no
running app involved:

```bash
pip install "geolibre[mcp]"
geolibre-mcp --root ~/maps
```

It builds projects through the same builders this package uses, so anything it
writes opens in the widget (and in the desktop and web apps) unchanged. See
[MCP server](mcp.md) for the tool list and client configuration.

## Building from source

The package lives in [`python/`](https://github.com/opengeos/GeoLibre/tree/main/python).
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ nav:
- UI Profiles: ui-profiles.md
- Internationalization: i18n.md
- Python Package: python.md
- MCP Server: mcp.md
- Notebook Panel: notebook.md
- Roadmap: roadmap.md
- Contributing: contributing.md
Expand Down
16 changes: 16 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,19 @@ m.to_project()["mapView"]["center"]
dataset is held in memory and re-synced on every project update. For very large
layers, prefer a tile or COG source (`add_tile_layer`/`add_cog`) the app fetches
directly.

## MCP server

The package also ships a headless [MCP](https://modelcontextprotocol.io) server
that authors `.geolibre.json` projects from an AI client:

```bash
pip install "geolibre[mcp]"
geolibre-mcp --root ~/maps
```

It confines every read and write to the roots you pass (`--root`, repeatable, or
`GEOLIBRE_MCP_ROOTS`) and builds projects through the same builders this package
uses, so anything it writes opens in the widget unchanged. See
[docs/mcp.md](https://geolibre.app/mcp/) for the tool list and client
configuration.
8 changes: 7 additions & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ dependencies = ["anywidget>=0.9", "traitlets>=5", "jupyter-ui-poll>=0.2"]

[project.optional-dependencies]
all = ["geopandas", "shapely"]
dev = ["pytest", "build", "anywidget[dev]", "jupyter-server"]
# The MCP server (geolibre.mcp) is the only thing that imports the SDK, so it
# stays optional: the widget and the project builders work without it.
mcp = ["mcp>=2.0"]
dev = ["pytest", "build", "anywidget[dev]", "jupyter-server", "mcp>=2.0"]

[project.scripts]
geolibre-mcp = "geolibre.mcp.server:main"
Comment thread
giswqs marked this conversation as resolved.
Outdated

[project.urls]
Homepage = "https://geolibre.app"
Expand Down
Loading
Loading