Skip to content

feat(python): add an MCP server for authoring GeoLibre projects - #1734

Merged
giswqs merged 12 commits into
mainfrom
feat/mcp-server
Aug 6, 2026
Merged

feat(python): add an MCP server for authoring GeoLibre projects#1734
giswqs merged 12 commits into
mainfrom
feat/mcp-server

Conversation

@giswqs

@giswqs giswqs commented Aug 6, 2026

Copy link
Copy Markdown
Member

Adds geolibre-mcp, a headless stdio MCP server that authors real .geolibre.json projects from an AI client. No browser, no running app, no bundled web build. It composes projects with the same builders the Python package already uses, so anything it writes opens unchanged in the desktop app, the web app, and the Jupyter widget.

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

Layering

The point of the structure is that nothing gets duplicated:

  • project.py keeps building pieces (a layer, a plugin-state blob).
  • New authoring.py applies them to a whole project: add/remove/reorder and restyle layers, classify a choropleth, move the camera, compose the legend/colorbar/swipe controls, summarize a project back.
  • Map now delegates its split-map, legend, colorbar, and choropleth composition to authoring.py rather than holding its own copy, so the widget and the MCP server cannot drift apart.
  • to_html splits into a module-level render_project_html() the server can call without a widget; Map.to_html delegates to it.

mcp/server.py is the only module that imports the SDK, behind the optional geolibre[mcp] extra, so the rest of the package is unaffected.

Tools (21)

Lifecycle: create_project, describe_project, list_catalog.
Layers: add_geojson_layer, add_vector_layer, add_raster_layer, add_tile_layer, add_tiles_layer, add_ogc_layer, add_3d_tiles_layer.
Editing: update_layer, remove_layer, style_layer, classify_layer, list_layer_properties.
Framing: set_view, set_basemap, add_legend, add_colorbar, add_swipe.
Export: export_html.

Layers are addressed by id or display name, so a client can work from what describe_project showed it. describe_project reports inlined GeoJSON as a feature count and never echoes it back.

Confinement

mcp/workspace.py resolves every path against roots given via --root (repeatable) or GEOLIBRE_MCP_ROOTS, mirroring the sidecar's GEOLIBRE_CONVERSION_ROOTS. Paths outside them are refused, as is a symlink inside a root pointing out of it. Writes are limited to .json and .html, and replacing an existing file needs an explicit overwrite. Remote GeoJSON fetches reuse the existing SSRF guard (every redirect hop checked, 50 MB cap).

Verification

Beyond the unit tests, a real client spawned the server as a subprocess over stdio, initialized, listed the 21 tools, and built a choropleth of 52 US states fetched from a live URL plus COG, XYZ, legend, colorbar, and swipe layers. The resulting project round-trips through the app's own parseProject with its graduated symbology (5 stops) and plugin state intact:

US states | type=geojson | vectorStyleMode=graduated | stops=5
Landsat   | type=cog
OSM       | type=xyz
activePluginIds: layer-control, deckgl-viz, atmosphere-effects, gl-swipe
components features: [ 'legend', 'colorbar' ]

A workspace escape is refused over the wire as an isError result.

Notes

  • set_view(bbox=...) is approximate by construction: a saved project stores center/zoom and the app applies those verbatim on load rather than fitting a stored bbox, so the server resolves the box to a camera itself against an assumed pane size. Documented in docs/mcp.md; pass center/zoom for exact framing.
  • tests/test_mcp_server.py skips itself without the SDK, so publish-python.yml installs mcp explicitly. Dropping that line would ship the server untested (green but hollow).

Testing

  • python -m pytest python/tests — 248 passed, 3 skipped, on 3.11 and 3.12 (the SDK needs >= 3.10, under the repo's 3.11 floor).
  • pre-commit run --files <changed> — clean.

Summary by CodeRabbit

  • New Features

    • Added an optional MCP server for creating, inspecting, editing, styling, and exporting GeoLibre projects.
    • Added command-line startup with stdio, HTTP, and SSE transport options.
    • Added authoring tools for layers, views, basemaps, legends, colorbars, and swipe controls.
    • Added standalone HTML project export support.
  • Bug Fixes

    • Improved workspace, path, symlink, file type, overwrite, URL, and export safety checks.
  • Documentation

    • Added MCP installation, configuration, usage, tools, and security guidance.
  • Tests

    • Added comprehensive coverage for authoring operations and MCP workflows.

Adds `geolibre-mcp`, a headless stdio MCP server that writes real
`.geolibre.json` projects from an AI client. No browser, no running app,
and no bundled web build: it composes projects with the same builders the
Python package already uses, so anything it writes opens unchanged in the
desktop app, the web app, and the Jupyter widget.

Layering, so nothing is duplicated:

- `project.py` keeps *building* pieces (a layer, a plugin-state blob).
- New `authoring.py` *applies* them to a whole project: add/remove/reorder
  and restyle layers, classify a choropleth, move the camera, compose the
  legend/colorbar/swipe controls, and summarize a project back.
- `Map` now delegates its split-map, legend, colorbar, and choropleth
  composition to `authoring.py` instead of holding its own copy, so the
  widget and the MCP server cannot drift.
- `to_html` splits into a module-level `render_project_html()` the server
  can call without a widget; `Map.to_html` delegates to it.

The server itself is `mcp/server.py` (21 tools), the only module that
imports the `mcp` SDK, kept behind the optional `geolibre[mcp]` extra.
`mcp/workspace.py` confines every read and write to roots given via
`--root` or `GEOLIBRE_MCP_ROOTS`, mirroring the sidecar's
`GEOLIBRE_CONVERSION_ROOTS`; symlinks out of a root are refused, writes
are limited to `.json`/`.html`, and an existing file needs `overwrite`.

Verified end to end: a client spawned the server over stdio, listed the
tools, and built a choropleth of 52 US states fetched from a live URL
plus COG, XYZ, legend, colorbar, and swipe layers; the resulting project
round-trips through the app's own `parseProject` with its symbology and
plugin state intact, and a workspace escape is refused over the wire.

`tests/test_mcp_server.py` skips itself without the SDK, so
publish-python.yml installs `mcp` explicitly rather than shipping the
server untested.
Copilot AI lite review requested due to automatic review settings August 6, 2026 04:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

GeoLibre adds a shared widget-free authoring layer and a workspace-confined MCP server. It adds project editing tools, HTML export, optional packaging, CI coverage, tests, and documentation.

Changes

MCP authoring

Layer / File(s) Summary
Shared project authoring
python/src/geolibre/authoring.py, python/src/geolibre/geolibre.py, python/tests/test_authoring.py, python/tests/test_scripting.py
Adds project persistence, layer mutation, styling, classification, views, basemaps, legends, colorbars, swipe controls, shared HTML rendering, and origin-restricted export messaging.
Workspace confinement and entry points
python/src/geolibre/mcp/workspace.py, python/src/geolibre/mcp/__init__.py, python/src/geolibre/mcp/__main__.py, python/tests/test_mcp_server.py
Adds root validation, symlink-aware path resolution, output validation, overwrite handling, package exports, module startup, and confinement tests.
MCP tools and server lifecycle
python/src/geolibre/mcp/server.py, python/tests/test_mcp_server.py
Adds project, layer, inspection, control, classification, and HTML export tools with configurable MCP transports and workspace-bound persistence.
Packaging, CI, and documentation
python/pyproject.toml, .github/workflows/publish-python.yml, docs/*, python/README.md, CLAUDE.md, mkdocs.yml
Adds the MCP optional dependency, geolibre-mcp command, CI SDK installation, and MCP setup and reference documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPServer
  participant Workspace
  participant Authoring
  MCPClient->>MCPServer: Invoke an authoring tool
  MCPServer->>Workspace: Resolve a confined project path
  Workspace-->>MCPServer: Return the validated path
  MCPServer->>Authoring: Load and mutate the project
  Authoring-->>MCPServer: Return a project summary
  MCPServer->>Workspace: Save the project
  MCPServer-->>MCPClient: Return the tool result
Loading

Poem

A rabbit tuned the map with care,
New MCP tools now hop through air.
Safe roots guard each project file,
Layers shift in tidy style.
HTML blooms in clear daylight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an MCP server for authoring GeoLibre projects.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mcp-server

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://199d28b6.geolibre-preview.pages.dev
Demo app https://199d28b6.geolibre-preview.pages.dev/demo/
Commit c2bbd44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Around line 86-87: Update the test reference in the MCP server documentation
to use the full repository-relative path python/tests/test_mcp_server.py,
replacing the shortened tests/test_mcp_server.py reference; leave the
surrounding workflow and SDK guidance unchanged.

In `@python/src/geolibre/authoring.py`:
- Around line 289-296: Update the values comprehension in the column-loading
logic to require that each feature’s properties value is a dictionary before
calling `.get(column)`, matching the existing `layer_properties` guard. Preserve
the current ValueError when no valid feature contains the requested column.
- Around line 91-94: Update the project-writing function containing the shown
Path logic to serialize into a temporary file in the destination’s parent
directory, then atomically replace the destination using os.replace. Preserve
parent-directory creation, UTF-8 encoding, JSON formatting, and the existing
Path return value; ensure temporary-file cleanup if replacement fails.

In `@python/src/geolibre/mcp/workspace.py`:
- Around line 112-116: Update the suffix validation around target.name so each
accepted filename has a non-empty stem before the matched suffix, rejecting bare
dotfiles such as “.json” and “.html” while preserving the existing allowed
suffixes and WorkspaceError behavior.
- Around line 48-55: Update the workspace constructor’s root validation around
_roots_from_env and the resolved list to reject an empty candidate set before
assigning self.roots. Raise WorkspaceError using the same fail-fast behavior as
invalid directory roots, while preserving existing expansion, resolution, and
validation for non-empty roots.

In `@python/tests/test_mcp_server.py`:
- Around line 85-88: Remove the unused is_dir() conditional in
test_workspace_rejects_traversal_out_of_a_root and initialize Workspace directly
with [tmp_path]. Keep the existing traversal assertion and expected
WorkspaceError unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e10a808a-e4dd-4a8e-aeef-bd3c8bb373c9

📥 Commits

Reviewing files that changed from the base of the PR and between 9768ba5 and c9cd715.

📒 Files selected for processing (15)
  • .github/workflows/publish-python.yml
  • CLAUDE.md
  • docs/mcp.md
  • docs/python.md
  • mkdocs.yml
  • python/README.md
  • python/pyproject.toml
  • python/src/geolibre/authoring.py
  • python/src/geolibre/geolibre.py
  • python/src/geolibre/mcp/__init__.py
  • python/src/geolibre/mcp/__main__.py
  • python/src/geolibre/mcp/server.py
  • python/src/geolibre/mcp/workspace.py
  • python/tests/test_authoring.py
  • python/tests/test_mcp_server.py

Comment thread CLAUDE.md Outdated
Comment thread python/src/geolibre/authoring.py
Comment thread python/src/geolibre/authoring.py Outdated
Comment thread python/src/geolibre/mcp/workspace.py
Comment thread python/src/geolibre/mcp/workspace.py Outdated
Comment thread python/tests/test_mcp_server.py
Comment thread python/src/geolibre/mcp/server.py
Comment thread python/src/geolibre/mcp/workspace.py
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • python/src/geolibre/mcp/server.py:23from mcp.server import MCPServer, together with mcp.server.mcpserver.exceptions.ToolError in test_mcp_server.py and the mcp>=2.0 pin in pyproject.toml, uses SDK names I don't recognize from the officially published mcp package (which historically exposes mcp.server.fastmcp.FastMCP, not a top-level MCPServer, and has no mcp.server.mcpserver submodule that I know of). If the installed mcp package doesn't actually expose this surface, the whole server module fails to import, breaking geolibre-mcp and every test in test_mcp_server.py. I have no network access to verify against current PyPI, so this is a "please double-check" rather than a confirmed bug. Confidence: medium.

Security

  • Filesystem confinement in workspace.py (root/symlink containment via Path.resolve(), extension allowlist, overwrite gate) and the remote-fetch SSRF/size guard reused from project.load_featurecollection both look correctly wired and match their existing test coverage. No new issues found. There's a theoretical TOCTOU between Workspace.resolve()'s symlink check and the later file read/write, but given the local single-operator stdio threat model described in the PR, this isn't worth blocking on. Confidence: low.

Performance

  • Nothing notable — operations are on small in-memory dicts and bounded-size files (50 MB inlined GeoJSON, 256 MB project cap).

Quality

  • workspace.py:22PROJECT_SUFFIXES = (".json", ".geolibre.json"): the second entry is redundant since it's a subset of the first. Cosmetic only. Confidence: low.
  • add_geojson_layer's local-vs-literal-GeoJSON heuristic (text.startswith(("{", "["))) is reasonable but means malformed literal GeoJSON that doesn't start with {/[ surfaces as a confusing "file not found in workspace" error instead of a JSON parse error. Minor UX nit, not filed inline since it's low-impact and speculative. Confidence: low.

CLAUDE.md

  • The new MCP-server bullet accurately describes the layering (project.py builds, authoring.py applies, server.py is the sole SDK-importing module, workspace.py mirrors GEOLIBRE_CONVERSION_ROOTS) and matches what's actually implemented. No discrepancies found.

Beyond the two inline notes above, I cross-checked every _project.* builder/helper call site in authoring.py and mcp/server.py against their real signatures in project.py (layer builders, legend/colorbar/swipe state, redact_credentials, etc.) and the new fit_bounds/Web Mercator math, and found them all consistent with the existing API and with the accompanying tests.

…nted render_mode

An audit of the tool surface found 6 of 21 tools with no test at the MCP
layer, and two more (add_tiles_layer, remove_layer) covered only on their
error paths. Some were covered one layer down in test_authoring.py, but
the argument plumbing in the tool wrappers was not.

Filling the gap turned up a real defect: add_vector_layer documented
render_mode as "geojson" or "vector-tiles", but project.vector_layer
accepts "geojson" or "tiles". A model following the docstring would have
hit a ValueError on every call. The docstring now names the real values,
and a test asserts the undocumented spelling is rejected, so the two
cannot drift again.

Adds happy-path coverage for every add_* tool (parametrized over the
builder each one plumbs, asserting the resulting layer type), plus
style_layer merge semantics, remove_layer, set_basemap, add_legend from
both a preset and paired lists, add_colorbar, and the legend/colorbar
coexistence in their shared settings blob.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/src/geolibre/mcp/server.py (1)

90-93: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Other (CWE-73)

Reachability: External

Restrict existing project paths to project extensions before reading/writing.

edit() accepts the MCP-controlled path, confines only the path and existence, then applies the mutation and writes authoring.save_project(file, project) back to the same file. Because authoring.load_project() accepts any JSON object and seeds layers when it is absent, a mutating tool can replace non-project JSON files such as package.json inside a workspace root.

Apply the same extension check used by create_project before every authoring.load_project()/authoring.save_project() call. Use the stricter .geolibre.json suffix in existing files unless .json is intentionally required and documented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/geolibre/mcp/server.py` around lines 90 - 93, The edit flow around
the project-loading generator must restrict existing files to the project
extension before any read or write. Reuse the extension validation used by
create_project, requiring the stricter .geolibre.json suffix, before
authoring.load_project and authoring.save_project in the relevant edit path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@python/src/geolibre/mcp/server.py`:
- Around line 90-93: The edit flow around the project-loading generator must
restrict existing files to the project extension before any read or write. Reuse
the extension validation used by create_project, requiring the stricter
.geolibre.json suffix, before authoring.load_project and authoring.save_project
in the relevant edit path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 50f60fa3-13f9-4014-ad03-c1a0fd23c879

📥 Commits

Reviewing files that changed from the base of the PR and between c9cd715 and b10ebfb.

📒 Files selected for processing (2)
  • python/src/geolibre/mcp/server.py
  • python/tests/test_mcp_server.py

Comment thread python/src/geolibre/mcp/server.py Outdated
Comment thread python/src/geolibre/mcp/workspace.py
Comment thread python/src/geolibre/mcp/server.py
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • create_project writes the initial camera via _project.build_empty_project(name, center=center, zoom=zoom), which does not clamp zoom to [0, 24], unlike every other camera-mutating path added in this PR (authoring.set_view, authoring.fit_bounds). An MCP client can write an out-of-range zoom directly into a new project. (python/src/geolibre/mcp/server.py:144, medium confidence)

Security

  • No new issues found. Workspace confinement (workspace.py) correctly resolves symlinks before the containment check for both reads and writes, rejects unsupported output extensions, and guards existing files behind overwrite; local-file GeoJSON reads are routed through workspace.resolve(..., must_exist=True) rather than the unconfined load_featurecollection path; HTML export continues to escape width/height/title/iframe_src and neutralizes < in the embedded JSON. (high confidence, reused/pre-existing patterns applied consistently to the new surface)

Performance

  • add_geojson_layer fetches/reads data via load_geojson() before the tool validates that path resolves inside the workspace, so an invalid destination still pays for the fetch/read first. Not a security hole (the SSRF guard still applies), just an avoidable ordering inefficiency. (python/src/geolibre/mcp/server.py:188-207, low confidence)

Quality

  • PROJECT_SUFFIXES = (".json", ".geolibre.json") is redundant since the endswith check already accepts anything ending in .json; the second entry never changes behavior. (python/src/geolibre/mcp/workspace.py:22, low confidence, cosmetic)
  • Minor inconsistency: the new authoring.add_swipe/MCP add_swipe default control_position to "top-right", while the existing Map.split_map defaults to "top-left" — not a bug, just a divergence between the two authoring surfaces worth a second look if it wasn't intentional.

CLAUDE.md

  • No violations found. CLAUDE.md itself was updated with a matching MCP-server description, the doc-nav (mkdocs.yml) and cross-links (docs/python.md, python/README.md) were kept in sync, and publish-python.yml installs mcp>=2.0 so tests/test_mcp_server.py doesn't ship untested, per the stated convention.

Overall the layering (project.py builds → authoring.py applies → Map and mcp/server.py both delegate) is clean and the workspace confinement/tests are thorough; findings above are minor.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site Deploy failed. See the job log.
Demo app Unavailable
Commit c2bbd44

- authoring.save_project: write to a temporary file in the destination's
  directory and os.replace it into place, so an interrupted write cannot
  truncate an existing project (the MCP edit path rewrites the whole file on
  every tool call).
- authoring.column_values: GeoJSON permits `"properties": null`, which made
  `.get(column)` raise AttributeError instead of the documented ValueError.
  Guard with isinstance, matching layer_properties.
- mcp/workspace.Workspace: refuse an empty root set at construction. A
  separators-only GEOLIBRE_MCP_ROOTS filtered down to no roots and every later
  relative-path resolve raised IndexError instead of WorkspaceError.
- mcp/workspace.resolve_output: require a non-empty stem before the suffix, so
  a bare dotfile named exactly `.json` or `.html` is refused as the comment
  already claimed.
- mcp/server.edit: run the destination through the same extension allowlist
  create_project writes through, and check the loaded JSON actually looks like
  a GeoLibre project. load_project accepts any JSON object and seeds `layers`,
  so a mutating tool could previously rewrite an unrelated `package.json`
  inside a workspace root as a map.
- mcp/server.create_project: route the initial zoom through authoring.set_view
  so it is clamped to [0, 24] like every other camera-mutating path.
- mcp/server.add_geojson_layer: resolve the destination before fetching or
  reading `data`, so a bad path fails without paying for the download first.
- mcp/workspace: note that `.geolibre.json` in PROJECT_SUFFIXES is a subset of
  `.json` and is listed to name the convention in the client-facing error.
- Tests for each of the above, and drop the dead `is_dir()` conditional in
  test_workspace_rejects_traversal_out_of_a_root.
- CLAUDE.md: use the repository-relative `python/tests/test_mcp_server.py`.
- docs/mcp.md: document the two new write guards.
@giswqs

giswqs commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Addressed the outside-diff finding on mcp/server.py:90-93 ("Restrict existing project paths to project extensions before reading/writing") in c97c1f8.

edit() now does two things before it loads or saves:

  1. Resolves through workspace.resolve_output(path, suffixes=PROJECT_SUFFIXES, overwrite=True), so an existing destination passes the same extension allowlist create_project writes through (then re-checks is_file(), since overwrite=True no longer implies existence).
  2. Calls _require_project(file, project), which refuses a loaded object carrying none of mapView / basemapStyleUrl / a non-empty layers.

I did not narrow the allowlist to .geolibre.json alone, because create_project documents .json as valid ("ending in .json, conventionally .geolibre.json") and doing so would lock the server out of projects it had itself created at foo.json. The extension check alone also would not have closed the reported hole — package.json ends in .json — which is what the shape check is for. test_editing_refuses_a_json_file_that_is_not_a_project asserts a package.json inside a root survives an add_geojson_layer call byte for byte, and test_editing_refuses_a_destination_with_an_unsupported_extension covers the extension path.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/src/geolibre/mcp/server.py (1)

835-836: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Write HTML exports atomically when overwrite is enabled.

destination.write_text truncates an existing export before the full HTML is written. A write failure or interruption can destroy the prior export. Reuse or extract the same temporary-file-and-os.replace helper used by authoring.save_project.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/geolibre/mcp/server.py` around lines 835 - 836, Update the HTML
export write flow around destination.write_text to use the existing
temporary-file-and-os.replace atomic-write helper from authoring.save_project,
particularly when overwrite is enabled. Ensure the temporary file is fully
written before replacing destination so failed or interrupted writes preserve
the previous export.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/src/geolibre/mcp/server.py`:
- Around line 251-253: Update the validation flow around workspace.resolve and
the edit operation to validate the complete destination before loading data:
enforce the same allowed suffix, file-type, and project-location checks used by
edit. Perform these checks before either reading a local file or fetching a URL,
while preserving the existing path-confinement and existence validation.

---

Outside diff comments:
In `@python/src/geolibre/mcp/server.py`:
- Around line 835-836: Update the HTML export write flow around
destination.write_text to use the existing temporary-file-and-os.replace
atomic-write helper from authoring.save_project, particularly when overwrite is
enabled. Ensure the temporary file is fully written before replacing destination
so failed or interrupted writes preserve the previous export.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1bcb9f83-7ca6-4ded-b67c-1c3c5a095b26

📥 Commits

Reviewing files that changed from the base of the PR and between b10ebfb and c97c1f8.

📒 Files selected for processing (7)
  • CLAUDE.md
  • docs/mcp.md
  • python/src/geolibre/authoring.py
  • python/src/geolibre/mcp/server.py
  • python/src/geolibre/mcp/workspace.py
  • python/tests/test_authoring.py
  • python/tests/test_mcp_server.py

Comment thread python/src/geolibre/mcp/server.py Outdated
Comment thread python/src/geolibre/mcp/server.py
Comment thread python/src/geolibre/authoring.py
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

This PR adds a headless geolibre-mcp stdio server plus the authoring.py layer it shares with geolibre.Map. The refactor of geolibre.py's split-map/legend/colorbar/to_html logic into authoring.py is a faithful move with no behavioral drift, and the workspace confinement (workspace.py) correctly resolves symlinks and compares real Path parents rather than doing a string-prefix check, avoiding the classic /data vs /database bypass. Test coverage (test_authoring.py, test_mcp_server.py) is thorough, including the symlink-escape and non-project-JSON-clobber cases.

Bugs

  • None found with meaningful confidence. Edge cases checked (empty-bbox fit, zero-feature column_values, opacity/zoom clamping, _require_project's falsy-layers truthiness check, output path resolution for not-yet-existing files) all resolve correctly or degrade gracefully.

Security

  • export_html's app_url is passed straight into render_project_html, becoming both the exported page's unsandboxed <iframe src> and the implicit recipient of a wildcard-targetOrigin postMessage carrying the (credential-redacted but otherwise unfiltered) project payload. No scheme/host validation exists, unlike the width/height CSS-dimension checks. This pattern is inherited from Map.to_html, but the MCP tool newly exposes it directly to model-chosen tool arguments, which widens the practical attack surface for indirect prompt injection. Medium-low confidence — posted inline on server.py:804.

Performance

  • No issues; each edit tool rewrites the whole project file, which is a documented, intentional tradeoff (MAX_PROJECT_BYTES caps it at 256 MB).

Quality

  • authoring.save_project's atomic replace via tempfile.NamedTemporaryFile can silently narrow a project file's permissions (e.g. 6440600) on the first MCP-driven edit, since the temp file's default mode isn't carried over. Low confidence / minor. Posted inline on authoring.py:103, along with a small docstring nit (add_geojson_layer says https:// only, but http:// is also accepted).

CLAUDE.md

  • Adherent: CLAUDE.md itself is updated with the new MCP module description, docs/mcp.md is added and wired into mkdocs.yml's nav, and publish-python.yml installs the mcp extra so test_mcp_server.py isn't silently skipped in CI — matching the PR's own stated rationale.

- render_project_html: require app_url to be an http(s) URL with a host, and
  post the project to that exact origin instead of "*". The MCP export_html
  tool takes app_url straight from a tool call, so a model reading untrusted
  content could be steered into posting a project — inlined features, layer
  URLs, camera — to an attacker's origin. Verified the default host
  (web.geolibre.app) serves the embed with no cross-origin redirect, so the
  strict targetOrigin does not break the default export.
- authoring.save_project: chmod the temporary file to the destination's mode
  before os.replace. NamedTemporaryFile creates at 0600, so the atomic write
  added in c97c1f8 would have narrowed a project the user had made
  group/world readable on its first edit.
- mcp/server.add_geojson_layer: load `data` inside the edit context, so the
  destination clears every check edit() makes — confinement, extension, and
  that the file really is a project — before the fetch or read is paid for.
  The previous pre-check only covered confinement and existence.
- mcp/server.add_geojson_layer: the docstring said `https://` URL; load_geojson
  accepts plain `http://` too.
- Tests for each of the above.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/src/geolibre/mcp/server.py (1)

829-835: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: External · Exploitability: Moderate

Reachability path
● Entry
  python/src/geolibre/mcp/server.py:834
  render_project_html
│
▼
● Sink
  python/src/geolibre/geolibre.py

Validate the export source as GeoLibre project shape before rendering it.

export_html only checks workspace existence and project-file suffix; authoring.load_project accepts any JSON object and seeds layers when absent. That can export unrelated workspace JSON, embed it in the exported page, and send it to the selected app origin. Call _require_project(file, project) before render_project_html() and add a behavioral test that export_html rejects a non-project JSON file such as package.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/geolibre/mcp/server.py` around lines 829 - 835, In export_html,
validate the loaded project with _require_project(file, project) immediately
after authoring.load_project and before render_project_html. Add a behavioral
test confirming export_html rejects a non-project JSON file such as
package.json.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@python/src/geolibre/mcp/server.py`:
- Around line 829-835: In export_html, validate the loaded project with
_require_project(file, project) immediately after authoring.load_project and
before render_project_html. Add a behavioral test confirming export_html rejects
a non-project JSON file such as package.json.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b62891c4-8631-4b91-a0d1-6dc049e45638

📥 Commits

Reviewing files that changed from the base of the PR and between c97c1f8 and 8d5cf0c.

📒 Files selected for processing (6)
  • python/src/geolibre/authoring.py
  • python/src/geolibre/geolibre.py
  • python/src/geolibre/mcp/server.py
  • python/tests/test_authoring.py
  • python/tests/test_mcp_server.py
  • python/tests/test_scripting.py

Comment thread python/src/geolibre/mcp/server.py Outdated
Comment thread python/src/geolibre/mcp/server.py
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • _require_project in python/src/geolibre/mcp/server.py:75 falls back to accepting any JSON object with a non-empty "layers" key, not just ones carrying mapView/basemapStyleUrl. Since load_project always normalizes layers to a list first, this weakens the "don't clobber an unrelated JSON file" guard the code and tests describe (only tested against a package.json with no layers key). Medium confidence — flagged inline with a suggested fix.
  • set_view's bbox + explicit center/zoom combination (python/src/geolibre/mcp/server.py:649-655) leaves a stale mapView.bbox after the explicit center/zoom overrides win, since authoring.set_view never clears it. The field is read by the frontend (StatusBar.tsx), so this can show a mismatched extent. Low-medium confidence, flagged inline.

Security

  • No issues found. The Workspace confinement (workspace.py) correctly resolves symlinks before the containment check and is covered by a symlink-escape test. The render_project_html/to_html refactor is actually a security improvement: it pins the exported page's postMessage targetOrigin to the parsed app_url's origin instead of "*", with validation that app_url is http(s) with a real host, and is covered by new tests (test_to_html_posts_the_project_to_the_app_origin_only, test_to_html_rejects_a_non_http_app_url).

Performance

  • Low confidence, not flagged inline: the edit() context manager in server.py does an unlocked read-modify-write of the whole project file. Since main() also offers streamable-http/sse transports (not just stdio), concurrent tool calls against the same project file could race and lose an update (last-writer-wins). This is a narrow scenario (multiple clients targeting the same file) and may not be worth guarding against given the primary stdio use case, so raising it only for awareness rather than as an inline comment.

Quality

  • The project.pyauthoring.pyMap/mcp/server.py layering is clean; the refactor of Map.split_map/add_legend/add_colorbar/to_html to delegate to authoring.py/render_project_html preserves prior validation and behavior (verified position clamping, mutually-exclusive legend sources, CSS-dimension checks, etc. all carried over correctly). Test coverage for the new authoring.py and mcp/server.py modules looks thorough (file I/O atomicity, layer lookup ambiguity, workspace confinement, extension/overwrite rules).

CLAUDE.md

  • The new docs/mcp.md file and the CLAUDE.md addition/reference-docs list update are consistent with the repo's documentation conventions; no violations found.

- mcp/server._require_project: drop the `or project.get("layers")` fallback.
  load_project normalizes `layers` onto whatever it loaded, and a top-level
  `layers` array is not exclusive to this format (map/style configs use one
  too), so the fallback let an unrelated config pass the guard the marker
  check exists to enforce. Requires `mapView` or `basemapStyleUrl` now, both
  of which every project this package or the app writes carries.
- authoring.set_view: clear `mapView.bbox` when `center` or `zoom` is set.
  fit_bounds records the bbox to describe the camera it computed; moving the
  camera by hand left it describing a different extent, which the app reads
  for the status bar's BBox readout. Bearing and pitch leave it alone, since
  neither changes the extent. `bbox` is optional in the schema and StatusBar
  renders "—" without it.
- Tests for both, plus the set_view tool docstring.
Comment thread python/src/geolibre/mcp/server.py
- Credential redaction no longer strips the first-party map controls. Both
  implementations blanked `plugins.settings` wholesale, so the documented
  compose-then-export flow (add_legend/add_colorbar/add_swipe -> export_html)
  produced a page with no legend, colorbar, or swipe — and Map.to_html and
  Map.save_project lost them too. A new PUBLISHABLE_PLUGIN_SETTINGS allowlist
  keeps the two first-party control plugins, whose state is known and carries
  no credentials, and still drops every external plugin's free-form blob, so
  the no-secret guarantee the wholesale wipe existed for is unchanged.
  Mirrored in packages/core/src/credentials.ts so the app's share/export path
  does not diverge from the Python one.
- project.load_featurecollection: cap literal GeoJSON text at 50 MB. Only the
  URL and file forms were bounded, so the "Capped at 50 MB" the MCP tool
  documents was not true for the one form an MCP client supplies directly.
- mcp/server.create_project: `overwrite=True` now means "replace the project
  there", not "replace whatever is there". PROJECT_SUFFIXES admits any `.json`,
  so an agent retrying after an "already exists" error could destroy an
  unrelated config with none of the safety net edit() applies.
- authoring.describe_project: report `swipe` only when its plugin is also in
  activePluginIds. A settings blob left behind by a deactivated control was
  reported as a live control. The legend and colorbar are drawn from settings
  alone, so they stay settings-only.
- authoring.column_values: distinguish a column absent from every feature from
  one present but null in all of them; only the first is worth retrying with a
  different name.
- Tests for each, including an export-keeps-its-controls test on both the MCP
  tool and Map.to_html, which is what the previous test missed.
Comment thread packages/core/src/credentials.ts Outdated
Comment thread python/src/geolibre/project.py Outdated
Comment thread python/src/geolibre/mcp/server.py Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • python/src/geolibre/mcp/server.py:225-237 (create_project) — when overwrite=True and the existing file at path fails to parse as a project (invalid JSON, a JSON array, or over MAX_PROJECT_BYTES), load_project raises ValueError, existing becomes None, and the _require_project "does this even look like a project?" guard is skipped entirely — so the file is silently clobbered. This is the inverse of the intended safety net (an unreadable file is exactly the case that should be refused, not waved through). Existing tests only cover a valid-but-non-project JSON object, not this path. Confidence: medium-high.

Security

  • packages/core/src/credentials.ts:329-341 and mirrored python/src/geolibre/project.py:163-176redactProjectCredentials/redact_credentials now keep the entire maplibre-gl-swipe/maplibre-gl-components settings blob verbatim (no redactConfigurationValue/URL-credential scrub) to preserve legend/colorbar/swipe state across exports. But maplibre-gl-components settings also carry the custom HTML panel block (ComponentHtmlGuiEntryState.html), which is free-form, user-authored text — a user could embed a URL with an API key/token there, and it would now survive export_html/credential redaction unscrubbed, contradicting the stated "known, credential-free" rationale for the allowlist. Confidence: medium.

Performance

  • Nothing notable found in the changed code — file/URL/text size caps (50 MB GeoJSON, 256 MB project) and streaming reads look consistent and were actually tightened (the literal-text GeoJSON path gained a missing size cap).

Quality

  • The authoring.py extraction is clean: Map and the MCP server now share one implementation for layer mutation, camera/basemap, and legend/colorbar/swipe composition, and the refactor preserves the prior validation behavior faithfully (checked set_view/fit_bounds bbox-vs-center precedence, add_swipe/add_legend/add_colorbar validation — all carried over correctly). The Workspace path-confinement logic (symlink resolution, root containment, extension/overwrite checks) looks sound.

CLAUDE.md

  • No violations found; the new "MCP server" bullet was added to CLAUDE.md as required, docs/mcp.md was added to the reference-docs list, and the credential-mirroring convention (PUBLISHABLE_PLUGIN_SETTINGS in both TS and Python) follows the repo's established "mirrored constant, must stay in sync" pattern — though see the Security note above regarding what that mirrored constant now permits through.

Both findings were holes in the round-5 fixes.

- PUBLISHABLE_PLUGIN_SETTINGS is now plugin id -> allowed sub-keys, not a flat
  id allowlist, and the components plugin lists only `legend` and `colorbar`.
  Its `html` sub-key holds a custom panel the user authored by hand, so it can
  carry anything — including a URL with a token — and keeping the whole blob
  would have let that survive an export. The swipe blob is structured
  throughout (layer ids, orientation, position, flags) and is still kept whole.
  What survives now also goes through the same credential sweep layer
  configuration gets, rather than being trusted verbatim; a blob with an
  unexpected shape is dropped rather than passed through. Mirrored in
  packages/core/src/credentials.ts so the two implementations still agree.
- mcp/server.create_project: a ValueError from load_project now refuses the
  overwrite instead of skipping the guard. A file that cannot be read as a
  project at all — malformed, a JSON array, over MAX_PROJECT_BYTES — is the
  case to refuse hardest, not the one where the check falls away. The error
  says to delete the file if it is unwanted, so a corrupted project is not a
  dead end.
- Tests for each, on both the Python and TypeScript sides.
Comment thread python/src/geolibre/authoring.py
Comment thread python/src/geolibre/authoring.py Outdated
Comment thread python/src/geolibre/mcp/server.py
Comment thread python/src/geolibre/authoring.py Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

All inline comments posted. Here's the final summary.

Code review

Bugs

  • authoring.py:584-598 (set_view) — center, bearing, zoom, and pitch aren't checked for finiteness before being written into mapView, unlike the sibling fit_bounds, which explicitly guards with math.isfinite. A client-supplied 1e400 parses to Python inf without error and gets serialized as a bare Infinity token by json.dumps, which is invalid JSON and would break strict parsers (e.g. the browser app's JSON.parse). Medium-high confidence.
  • authoring.py:362-377 (remove_layer) — removing a layer doesn't clean up dangling references to its id in the swipe control's leftLayers/rightLayers, left silently stale with no error surfaced. No test covers this interaction. Medium confidence.

Security

  • No injection, SSRF, path-traversal, or credential-leak issues found. mcp/workspace.py's root confinement (symlink-following resolve(), extension allowlist, overwrite gating) is sound and consistently used by server.py's edit()/export_html paths; the SSRF guard is correctly the only network path (only add_geojson_layer fetches remotely; other add_*_layer tools just store a URL string). The new PUBLISHABLE_PLUGIN_SETTINGS credential-redaction allowlist matches exactly between packages/core/src/credentials.ts and python/src/geolibre/project.py, and is actually wired into both redaction paths (not dead code).

Performance

  • Nothing notable. No unbounded loops over attacker-controlled data, no redundant reparsing found in the reviewed paths.

Quality

  • mcp/server.py:115-121 (_build_layer) — the style vs. builder-parameter collision check only compares against the tool's own forwarded kwargs, not the underlying project.py builder's full signature. Builder parameters not exposed as named tool args (e.g. vector_layer's picker/ingest_mode, vector_tiles_layer's singular source_layer) pass the check silently and bind as real, behavioral parameters instead of being rejected or treated as decorative style — a silent contract gap versus the "style overrides" docstrings. Medium confidence.
  • authoring.py:112os.chmod(temporary, file.stat().st_mode) passes the raw st_mode (including file-type bits) rather than stat.S_IMODE(...); harmless on POSIX today but non-idiomatic. Low confidence, minor nit.
  • Test coverage gaps worth noting (not required for this PR, but flagged by both reviewing agents): no test for non-finite set_view inputs, no test for remove_layer interacting with an existing swipe reference, and no test for repeated add_legend/add_colorbar calls actually accumulating rather than overwriting.

CLAUDE.md

  • No violations found. The MCP server is correctly documented in CLAUDE.md's architecture section, docs/mcp.md is added to the reference-docs list and mkdocs.yml nav, the PUBLISHABLE_PLUGIN_SETTINGS Python/TS mirror requirement is honored, and publish-python.yml installs the mcp extra so test_mcp_server.py isn't silently skipped in CI.

- authoring.set_view: reject non-finite camera values. `json.loads` turns an
  out-of-range literal like `1e400` into `inf` without raising, and
  `json.dumps` writes it back as a bare `Infinity` token — not valid JSON per
  RFC 8259, so the app's JSON.parse rejects the whole project. center, zoom,
  bearing, and pitch all go through a `_finite` check now, matching
  fit_bounds. save_project also passes allow_nan=False as a backstop, so no
  other path can write a project the app cannot read.
- authoring.remove_layer: drop the removed layer's id from a swipe control's
  leftLayers/rightLayers. The split kept a dangling reference with nothing
  surfacing it. The basemap pseudo-id is not a layer, so it is untouched.
- mcp/server._build_layer: check `style` against the builder's whole
  signature, not just the arguments the tool forwards. vector_layer takes
  `picker`/`ingest_mode` and vector_tiles_layer takes a singular
  `source_layer`, none of which the tools expose — so `style={"picker": False}`
  bound to real behavior while the docstring promised style overrides. This
  also catches the positional case up front, leaving the TypeError branch as a
  backstop.
- authoring.save_project: use stat.S_IMODE when carrying the destination's
  mode over, so only the permission bits are copied.
- Tests for each.
Comment thread python/pyproject.toml Outdated
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Reviewed the full diff (docs, packages/core/src/credentials.ts + its Python mirror in project.py, the new authoring.py/mcp/workspace.py/mcp/server.py modules, the geolibre.py refactor, and both the new and touched test suites), cross-checking the security-critical paths (workspace path confinement/symlink handling, SSRF guard on remote GeoJSON fetches, the postMessage origin pin in HTML export, and the plugin-settings credential redaction logic) against their actual behavior rather than just the diff text.

Bugs

  • None found with reasonable confidence. The authoring.py/geolibre.py refactor (extracting build_choropleth_style, add_legend, add_colorbar, add_swipe, render_project_html out of Map) is a faithful, behavior-preserving extraction — verified to_html's height or self.height default still resolves correctly through the new render_project_html free function, and the credential-redaction call remains in place.

Security

  • workspace.py's path confinement (root check via resolve() + parents, symlink resolution, extension allowlist, bare-dotfile rejection, overwrite gating) is correctly implemented and matches its test coverage (test_workspace_rejects_a_symlink_escape, traversal, outside-root, etc.). No bypass found. Confidence: high.
  • The PUBLISHABLE_PLUGIN_SETTINGS credential-redaction change in credentials.ts/project.py (keeping legend/colorbar/swipe composition while still sweeping the kept blob for credential-shaped fields, and dropping the components plugin's free-form html sub-key) is correct and the two implementations stay in parity. Verified against consumers (share-geolibre.ts, etc.) — they use the redaction result opaquely, so nothing depends on the old "settings always empty" contract. Confidence: high.
  • render_project_html's pin of the export's postMessage target to the parsed app_url origin (instead of "*") is a real hardening fix and is validated (scheme/host check, CSS-dimension regex to prevent <style> injection, < escaping in the inlined JSON). Confidence: high.
  • SSRF guard (_assert_public_url, redirect-hop re-validation, 50 MB cap) is unchanged from existing code and correctly reused by the new load_geojson path in server.py. Confidence: high.

Performance

  • Nothing notable; operations are on in-memory dicts and small JSON files, with explicit size caps (50 MB inlined GeoJSON, 256 MB project file) already in place.

Quality

  • Minor UX nit (posted inline, low confidence/severity): geolibre-mcp's console-script entry point imports geolibre.mcp.server unconditionally, so running it without the optional mcp extra installed surfaces a raw ModuleNotFoundError traceback rather than an actionable message — inconsistent with how carefully errors are handled elsewhere in this PR (e.g. WorkspaceError messages, _build_layer's collision errors).
  • Everything else — layering (project.py builds / authoring.py applies / Map and server.py both delegate to authoring.py), naming, and docstrings — is clear and consistent with the stated design goals.

CLAUDE.md

  • The new MCP section and the mirrored-constant convention (PUBLISHABLE_PLUGIN_SETTINGS "the two must agree") follow the repo's existing pattern for documenting cross-language mirrors that can silently drift; the pairing is correctly cross-referenced from both credentials.ts and project.py. docs/mcp.md is correctly added to the reference-docs list and mkdocs.yml nav. No violations found.

Overall this is a well-scoped, defensively coded PR with strong test coverage (workspace confinement, SSRF, credential redaction parity, HTML-export injection surfaces are all exercised on both the TS and Python sides). I did not find any correctness or security defects worth blocking on.

- geolibre-mcp now points at a package-level `geolibre.mcp:main` wrapper that
  translates a missing `mcp` extra into an actionable line ("Install it with:
  pip install \"geolibre[mcp]\"") and exit code 1, instead of a raw
  ModuleNotFoundError traceback. `python -m geolibre.mcp` goes through the same
  wrapper. A dependency missing from underneath an installed SDK still
  propagates, since that is a broken environment rather than a missing extra
  and its traceback is the useful thing to show.
- Tests for both branches, plus a real check that `geolibre-mcp --help` still
  works through the new entry point.
Comment thread python/src/geolibre/authoring.py
Comment thread packages/core/src/credentials.ts Outdated
Comment thread python/src/geolibre/mcp/server.py
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • add_layer in python/src/geolibre/authoring.py (and every add_*_layer tool that goes through it) never rejects the reserved "__basemap__" name when creating a layer, even though update_layer explicitly refuses to rename a layer to it. A layer created with that name becomes unreachable by name in resolve_layer_ids/add_swipe (it silently resolves as the basemap instead), with no error surfaced. Medium confidence.

Security

  • export_html's app_url is fully caller-controlled and becomes the exact postMessage targetOrigin the (credential-stripped, but otherwise unredacted) project is posted to. This is a solid fix over the previous "*" target, but in an MCP/agent context where tool arguments can be influenced by untrusted content (prompt injection), a steered call could exfiltrate inlined GeoJSON/layer URLs to an attacker-controlled host when the exported HTML is later opened. Likely an accepted tradeoff for the "pin a self-hosted deployment" feature; worth documenting as a trust boundary. Low confidence.
  • Workspace confinement (mcp/workspace.py), SSRF guards on remote GeoJSON fetches, the project-marker check before overwriting JSON files, and the extension/overwrite rules are all careful and well-tested — no issues found there.

Performance

  • Nothing notable. Project files and inlined GeoJSON are size-capped (MAX_PROJECT_BYTES, _MAX_GEOJSON_BYTES), and save_project writes atomically via a temp file + os.replace.

Quality

  • packages/core/src/credentials.ts: the redacted plugin settings are cast as Record<string, never>, which is misleading — the object frequently holds real, populated data (legend/colorbar/swipe state); casting to the field's actual declared type (Record<string, unknown>) would be equally valid and less confusing to future readers. Low confidence, suggested a one-line fix inline.
  • The Python (project.py/authoring.py) and JS (credentials.ts) redaction logic for PUBLISHABLE_PLUGIN_SETTINGS were checked side-by-side and are consistent; test coverage on both sides (new/updated tests in test_scripting.py, test_authoring.py, test_mcp_server.py, project-credentials.test.ts) is thorough, including the antimeridian bbox math, credential redaction of "kept" plugin blobs, and workspace symlink-escape cases.

CLAUDE.md

  • The MCP server section added to CLAUDE.md accurately reflects the new mcp/ layering, the authoring.py/project.py split, and the publish-python.yml test-dependency note; docs/mcp.md is correctly linked from mkdocs.yml and the reference-docs list. No inconsistencies found.

- authoring.add_layer: enforce the reserved "__basemap__" name at creation,
  not only on rename. The round-4 guard covered update_layer but a client
  could still create a layer wearing the sentinel, which resolve_layer_ids
  would then treat as the basemap for any name-based reference. The check is
  now one _reject_reserved_name helper both paths call.
- packages/core/src/credentials.ts: cast the redacted settings to
  Record<string, unknown>, matching the field's declared type, rather than
  Record<string, never> — which claims no property may have a value and only
  type-checked because the value flowed through `unknown`.
- docs/mcp.md: document `app_url` as a trust boundary on export_html. The
  exported page posts the project to exactly that origin, and the caller is a
  model that may be acting on content it has read. Credentials are already
  stripped so this is not a key leak, but the rest of the project travels, and
  the residual risk is worth stating rather than leaving implicit.
- Test that add_layer refuses the reserved name.
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Waiting for the background review agents to complete before synthesizing the final report.

The server was registered and healthy, all 21 tools loaded, and a model
asked to "make me a choropleth of US state population density" still
wrote geopandas + matplotlib + folium by hand and never called it.

The descriptions said what each tool does, never when to use it.
`create_project` read "Create a new, empty GeoLibre project file" -- true,
and useless to a model deciding whether this is the right path for a map
request. Since create_project is the entry point, nothing downstream could
fire either.

Rewrites the server instructions and the four entry-point descriptions
(create_project, add_geojson_layer, classify_layer, export_html) to lead
with the user intents they serve -- "make me a map of X", "a choropleth of
Y", "show this on a map" -- and to say plainly when hand-written plotting
code is the better answer instead (a static figure for a paper, a
projection GeoLibre cannot render).

Measured, not assumed. Same prompt, same clean directory, before and
after: 0 geolibre calls before, 11 after, in the intended order --
list_catalog, create_project, add_geojson_layer, classify_layer, set_view,
add_legend, export_html, describe_project -- producing a valid project
with 51 states classified into 7 quantile breaks and a 127 KB standalone
page.
Comment on lines +2034 to +2048
# Layer objects are resolved to ids here (authoring.py works on plain
# project dicts and knows nothing about the Layer handle); the rest of
# the validation and state building is shared with the MCP server.
left = self._coerce_layer_ids(left_layers)
right = self._coerce_layer_ids(right_layers)
clamped = min(100.0, max(0.0, float(position)))
state = _project.swipe_state(
left_layers=left,
right_layers=right,
orientation=orientation,
position=clamped,
)

def mutate(p: dict[str, Any]) -> None:
_project.set_plugin_state(
self._update_project(
lambda p: _authoring.add_swipe(
p,
_project.SWIPE_PLUGIN_ID,
state,
position=control_position,
left_layers=left,
right_layers=right,
orientation=orientation,
position=position,
control_position=control_position,
)

self._update_project(mutate)

def _update_components_state(
self, key: str, entry_state_builder: Callable[[Any], dict[str, Any]]
) -> None:
"""Merge one feature's state into the Components plugin settings.

The Components plugin (legend / colorbar / html) stores all its features
under a single settings blob keyed by feature name, so a new legend must
be merged in without dropping an existing colorbar (and vice versa).

Args:
key: The feature key (``"legend"`` or ``"colorbar"``).
entry_state_builder: Called with the feature's current state (or
``None``) and returns its new state.
"""

def mutate(p: dict[str, Any]) -> None:
plugins = _project.ensure_plugins_block(p)
current = plugins["settings"].get(_project.COMPONENTS_PLUGIN_ID)
components = dict(current) if isinstance(current, dict) else {}
components[key] = entry_state_builder(components.get(key))
# The legend/colorbar restore from their settings blob alone, so the
# plugin is configured but not added to activePluginIds (activating
# it would also mount the full Components toolbar).
_project.set_plugin_state(
p,
_project.COMPONENTS_PLUGIN_ID,
components,
activate=False,
)

self._update_project(mutate)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation order changed here: before this refactor, split_map checked orientation/control_position before coercing the layer arguments (see the removed lines in the diff). Now _coerce_layer_ids runs first, and the orientation/control_position checks happen later, inside authoring.add_swipe.

Concretely, m.split_map(123, orientation="bad") used to raise "orientation must be one of [...], got 'bad'" and now raises "Layer reference must be a layer id string, a Layer, or a list of those; got 123" instead — the wrong argument gets blamed first when both are invalid. Low impact (both still raise ValueError), but any code/docs relying on the old precedence would see a different message/exception path. Confidence: medium.

Comment on lines +449 to +475
def add_tile_layer(
path: str,
name: str,
url: str,
tile_size: int = 256,
attribution: str | None = None,
index: int | None = None,
) -> dict[str, Any]:
"""Add a raster XYZ tile layer from a `{z}/{x}/{y}` URL template.

This is how you add OpenStreetMap-style raster basemaps and imagery
services. A vector *style* (as opposed to tiles) belongs in
`set_basemap` instead.

Args:
path: Path to the `.geolibre.json` file.
name: The layer's display name.
url: Tile URL template containing `{z}`, `{x}`, and `{y}`.
tile_size: Tile edge in pixels, usually 256.
attribution: Attribution text to credit the source.
index: Draw-order position; appended on top when omitted.

Returns:
The new layer's id and the project's updated layer count.
"""
layer = _project.tile_layer(name, url, tile_size=tile_size, attribution=attribution)
return add(path, layer, index)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_tile_layer (raster XYZ) is the only add_*_layer tool that doesn't accept a style parameter or route through _build_layer — it calls _project.tile_layer(...) directly. Every sibling tool (add_vector_layer, add_raster_layer, add_tiles_layer, ...) lets the caller set style overrides (opacity, etc.) at creation time via _build_layer. Here a client has to create the layer and then make a separate style_layer/update_layer call to set anything beyond tile_size/attribution. Given how closely-named add_tile_layer and add_tiles_layer are, this asymmetry is easy to miss and looks like drift rather than a deliberate design choice. Confidence: medium.

Comment on lines +61 to +70
def call_error(server, tool, /, **arguments):
"""Invoke a tool expected to fail and return its error text.

A tool that raises surfaces as a ``ToolError`` from this in-process entry
point; over the wire the same failure reaches the client as an ``isError``
result.
"""
with pytest.raises(ToolError) as excinfo:
asyncio.run(server.call_tool(tool, arguments))
return str(excinfo.value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call_error only asserts that the in-process server.call_tool(...) raises ToolError; it never inspects CallToolResult.is_error/isError. Every workspace-escape/refusal test in this file goes through this helper, so none of them actually verify the PR description's specific claim that "a workspace escape is refused over the wire as an isError result" — the docstring here concedes as much ("over the wire the same failure reaches the client as an isError result", asserted only in a comment). Contrast with call above, which does check result.is_error on the success path. If the SDK's in-process vs. wire error surfacing ever diverges, this suite stays green while the actual client-facing contract breaks. Consider adding at least one test that drives a tool call through the wire-facing path (or asserts on result.is_error/result.content the way call does) to pin down the documented behavior. Confidence: medium-high.

Comment on lines +77 to +92
it("keeps the first-party map controls so an export still renders them", () => {
const original = credentialProject();
original.plugins!.settings = {
external: { arbitraryName: "plugin-secret" },
"maplibre-gl-components": { legend: { A: "#112233" } },
"maplibre-gl-swipe": { position: 50 },
};
const { project, redactedPaths } = redactProjectCredentials(original);
const settings = project.plugins!.settings;

assert.deepEqual(settings["maplibre-gl-components"], { legend: { A: "#112233" } });
assert.deepEqual(settings["maplibre-gl-swipe"], { position: 50 });
// An unknown plugin's blob is free-form and can hold a key, so it still goes.
assert.ok(!("external" in settings));
assert.ok(redactedPaths.includes("plugins.settings"));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test exercises legend and maplibre-gl-swipe (the null allow-all case) but never exercises the colorbar sub-key, which is the other entry in PUBLISHABLE_PLUGIN_SETTINGS["maplibre-gl-components"] (credentials.ts:53). A regression that drops/renames colorbar in the allow-list wouldn't be caught by this suite — the exact class of silent-export-breakage bug this PR fixes for legend. Worth adding a colorbar assertion alongside the existing legend one here. Confidence: medium.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Map.remove_layer (python/src/geolibre/geolibre.py:1904-1914) never calls authoring.remove_layer, so unlike the MCP server's remove_layer tool it doesn't strip a deleted layer's id from an active swipe control's leftLayers/rightLayers, leaving a dangling reference in the saved project. High confidence.
  • Map's own layer-by-name resolution (find_layer/_resolve_layer, geolibre.py:947-969) is case-sensitive and silently picks the first match on a duplicate name, while the new authoring.find_layer (authoring.py:163-197) used by the MCP tools is case-insensitive and raises on ambiguity — the same project behaves differently depending on which surface (notebook API vs. MCP tool) is used. Medium-high confidence.
  • split_map's validation order regressed: layer-id coercion now runs before orientation/control_position validation (moved into authoring.add_swipe), so a call with both a bad layer ref and a bad orientation now raises a different exception than before the refactor (geolibre.py:2034-2048). Medium confidence, low impact (still a ValueError either way).

Security

  • No issues found. mcp/workspace.py's path confinement correctly resolves symlinks before the containment check (no naive-prefix bug), enforces the .json/.html extension allowlist and overwrite flag consistently, and the SSRF/redirect/size-cap guard for remote GeoJSON fetches is genuinely reused rather than bypassed for the new tool surface. Minor, low-severity note (not inlined): WorkspaceError messages echo the full resolved absolute path and the list of configured roots, which is a very small amount of host-layout information disclosed on every refused request.

Performance

  • No problems identified in the reviewed code.

Quality

  • add_tile_layer (mcp/server.py:449-475) is the only add_*_layer MCP tool without a style parameter and doesn't route through the shared _build_layer helper like its siblings (including the similarly-named add_tiles_layer), so a client can't set style overrides at creation time — likely drift rather than intentional. Medium confidence.
  • test_mcp_server.py's call_error helper (lines 61-70) only asserts that the in-process call raises ToolError; it never checks CallToolResult.is_error, so none of the workspace-escape/refusal tests actually verify the PR description's specific claim that a workspace escape "is refused over the wire as an isError result" (the helper's own docstring concedes this gap). Medium-high confidence.
  • tests/project-credentials.test.ts tests the legend and maplibre-gl-swipe allow-list entries but never exercises the sibling colorbar sub-key, so a regression there wouldn't be caught. Medium confidence.
  • Minor, not inlined: build_choropleth_style always reports vectorStyleClassCount from the requested class count even when graduated_stops collapses to a single stop (all-equal data), so a consumer trusting that count to predict len(vectorStyleStops) gets a mismatch; pre-existing behavior now centralized in authoring.py, so both Map.add_choropleth and the MCP classify_layer tool inherit it. Low-medium confidence.

CLAUDE.md

  • No violations found. The MCP addition is documented in docs/mcp.md and referenced from CLAUDE.md's reference-docs list; the mcp SDK install step was correctly added to publish-python.yml per the stated convention that test_mcp_server.py would otherwise ship untested.
  • Worth flagging for discussion rather than as a defect: the frontend packages/core/src/credentials.ts change is a legitimate, well-documented fix (previously wiped all plugins.settings, now preserves legend/colorbar/swipe composition) mirrored correctly in python/src/geolibre/project.py, but it's a standalone desktop/web bug fix bundled into a PR titled "add an MCP server," and nothing in CI enforces the two mirrors (PUBLISHABLE_PLUGIN_SETTINGS in TS and Python) stay in sync going forward.

@giswqs
giswqs merged commit 750a80c into main Aug 6, 2026
16 checks passed
@giswqs
giswqs deleted the feat/mcp-server branch August 6, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants