Skip to content

8.0.0

Latest

Choose a tag to compare

@markuman markuman released this 08 Sep 15:23
· 10 commits to latest since this release

sms 8.0.0 — MCP tour planning

LLM agents can now plan hiking and trekking tours through sms and export them as
GPX — no external routing engine, no additional data sources,
no new dependencies.

New

MCP endpoint POST /mcp

Stateless JSON-RPC 2.0 (streamable HTTP without SSE). initialize,
tools/list, tools/call, ping, notifications, batches. GET /mcp → 405
(spec-compliant — there is no event stream).
Hand-rolled instead of the official python-sdk: that one is asyncio/starlette
based and does not mount into a gevent WSGI server. ~150 lines in
simple_mbtiles_server/mcp.py.

5 tools

Tool Purpose Requires
geocode place name → coordinates PHOTONSERVER
reverse_geocode coordinates → address PHOTONSERVER
search_poi huts, water, resupply —
plan_route multi-waypoint tour + GPX —
export_gpx arbitrary coordinates → GPX —
plan_route takes waypoints: [[lat,lon], ...] and chains segments: max. 50 km
straight-line distance per segment, unlimited total tour. If one segment fails,
the others are still returned and the failure is reported per segment in
errors.
Tool results contain no geometry, only metadata plus a gpx_url — a
3000-point LineString as text destroys any context window.

GPX export

GET /v1/gpx/<id>.gpx, GPX 1.1 with <wpt> waypoints and interpolated <ele>
values from contours.mbtiles. Cleanup by TTL and max count on every write.

Routing: corridor instead of bounding box

Tiles are loaded along the straight line rather than as a rectangle:

Route Corridor Old bbox Factor
30 km diagonal 112 484 4.3x
50 km diagonal 276 1260 4.6x
50 km axis-parallel 99 100 1.0x
For axis-parallel routes the bbox is naturally narrow, so the default buffer is
capped at the bbox's short side — the corridor is therefore never worse than
a plain bbox.
New guards: ROUTE_MAX_TILES (1200, OOM guard), ROUTE_MAX_CROW_KM
(50 km/segment). Range nearly doubled (30 → 50 km) while using less memory than
before.

Tile cache

Three LRU caches (roads/contours/POI), size via TILE_CACHE_SIZE. Tiles are
decoded profile-agnostically — one cache entry serves both foot and
bike. Hit/miss counters in /v1/capabilities and in
properties.cache_hits. Measured: 48/48 hits on a second request for the same
region.

Elevation profile

elevation=true on /v1/route returns ascent_m/descent_m from
contours.mbtiles; for foot the duration is then estimated per DIN 33466 /
SAC (300 m ascent / 500 m descent per hour) instead of a flat 4.5 km/h.
Crossing detection uses a ~500 m grid index — without it the cost is
O(path points × contour segments) and unusable over 50 km.

Fixes

Snapping was component-blind (critical). Vector tiles are clipped at tile
borders, so the decoded graph falls apart into one large component plus over a
thousand fragments (Garmisch, 25 tiles: 31,697 nodes, 1306 components, largest
78 %). The geometrically nearest node was regularly a 2-node stub — every
route failed, even 200 m inside town. _snap_pair() now snaps both endpoints
onto the same connected component.
alpine_hut returned mostly bus stop shelters. OpenMapTiles files bus stop
shelters and public air-raid shelters under subclass=shelter — around Garmisch
that is 113 of 122 hits, mostly unnamed. Meilerhütte and Weilheimer Hütte ended
up beyond position 30. alpine_hut now matches real huts only; shelter and
camp_site are separate categories.
POI names were missing. Only 18 of 122 POIs carry a plain name; there is
now a fallback to name_de/name:latin/name_en. Sorting: named POIs first
within the same ~500 m band.
Duplicate get_poi definition shadowed the new implementation, which left
distance_km and the POI cache dead.

Breaking / migration

PHOTONSERVER has two consumers. The value is baked into index.html via
sed, so the browser talks to that URL directly — it has to stay publicly
reachable. The MCP tools, however, call Photon from inside the container,
where the public hostname often resolves to a LAN address the container network
cannot route to (split-horizon DNS → Connection refused).
Hence the new PHOTONSERVER_INTERNAL — used for server-side calls only,
without breaking the frontend. If unset, it falls back to PHOTONSERVER
(existing single-host setups are unaffected).

podman network create osm
podman run -d --rm --network osm --name photon \
  -e UPDATE_STRATEGY=DISABLED -p 8888:2322 \
  -v /home/m/osm/photon/:/photon/data \
  docker.io/rtuszik/photon-docker:2.1.1
podman run -ti --rm --network osm --name sms -p 9000:9000 \
  -e PHOTONSERVER="https://photon.osuv.de" \
  -e PHOTONSERVER_INTERNAL="http://photon:2322" \
  -v /home/m/osm/sms/:/data/ localhost/sms:dev

New env vars

Variable Default Purpose
PHOTONSERVER_INTERNAL – Photon URL for server-side calls
TILE_CACHE_SIZE 2000 Decoded road tiles kept in memory
ROUTE_MAX_TILES 1200 Corridor tile limit per segment
ROUTE_MAX_CROW_KM 50 Straight-line limit per segment
GPX_DIR $TMPDIR/sms-gpx Where generated GPX files are stored
GPX_MAX_FILES 200 Max number of GPX files kept
GPX_TTL_SECONDS 86400 Lifetime of generated GPX files

Frontend

New POI buttons "Campingplatz" (⛺) and "Unterstand" (⛱️); "Schutzhütte" →
"Berghütte" (🛖). The JS categories are aligned with the server filters and
pinned together by a test — otherwise the two copies drift apart and you end up
with buttons that find nothing.

What the agent cannot know

Stated in every tool description and belongs in every tour answer: vector tiles
contain no sac_scale, trail_visibility, via_ferrata_scale, ele or
surface. A T1 forest stroll and a T5 scramble are indistinguishable in this
data. Turn restrictions and access tags are missing as well. Tracks must be
cross-checked against a topographic map.

Measurements that contradict the original assumption

A* does not halve expansions — measured ~5 % fewer (5305 instead of 5598).
Reason: the corridor already restricts the search space to the shape of the
answer, which is exactly what A* would otherwise contribute. On a route
traversing the whole loaded graph, A* is therefore ~1.5x slower than
Dijkstra, and ~2.7x faster for a short route in a warm cache. Absolute times are
single-digit milliseconds, so it hardly matters in practice. A* stays
(admissible, result equals Dijkstra's optimum, covered by a test); the heuristic
was made cheaper (equirectangular plus memoisation).
No z13 fallback for routing. path/footway have minzoom 14 in
OpenMapTiles — on z13 exactly the ways that matter for hiking disappear.

Tests

test/test_routing_mcp.py, 40 tests, MBTiles fixtures generated in-process
(~96 kB, no downloads, no external services):

python3 -m pytest -v test/test_routing_mcp.py

The old test_mbtiles_s3_server.py requires an S3 mock and the legacy
mbtiles_s3_server module and no longer runs in this repo.

Known limitations

  • GpxPod import untested. The GPX structure was verified against GPX 1.1
    (namespace, element order, escaping, lat/lon order, 18/18), but no real
    Nextcloud GpxPod instance was available. Schema-compliant ≠ accepted by
    GpxPod.
  • Elevation figures are accurate to roughly ±15 m (depending on the contour
    interval).
  • POI names are missing wherever they are missing in OSM — this affects e.g. 2
    of the 5 huts around Garmisch.