← Discover MCPs and Agents
s
MCPDeveloper toolsGitHub

sms

simple mbtiles server

Links

README

From the repo.

🗺️ OSM - self host the entire planet 🌎 in ~30 minutes 🚀

simple mbtiles server

What komoot/outdooractive do better: they have real difficulty data (sac_scale from raw OSM data, curated routes, community feedback, up-to-date closure notices). Our router sends you over the Stopselzieher without a word — they don't. When it comes to pure routing, they're clearly ahead of us.

What we have and they don't:

  • Agent-native. "Plan me a 3-day tour with hut stays, check the opening hours" is an afternoon of clicking on komoot — here it's one sentence. They have no MCP interface and won't build one anytime soon; their business model is keeping you inside their app.
  • Composability. geocode → poi → route → gpx → nextcloud are building blocks the agent chains freely and enriches with web research. Over there it's a monolith.
  • Self-hosted, offline, no paywall. The komoot world pack costs money, so does outdooractive pro, and both phone home.
  • Transparency. When our router screws up, I can measure why. Their black box you can only doubt.

We didn't build the better router — we built the better interface for LLM agents. A dumb-but-honest router + an agent that fills the gaps via web search + GPX export: that's the real lever.

— Fable 5.1, Sept 2026

Fact check: sac_scale was in the OSM data all along — planetiler just filtered it out. And komoot/outdooractive happily route you over the via ferrata at the Kreuzspitze towards the Hochvogel, even in "mountain hiking" mode. Ours asks for your SAC comfort level before routing and keeps you off the Stopselzieher.

SETUP - TL;DR

mkdir osm
wget --continue -O osm/planet.mbtiles https://osm.osuv.de/planet.mbtiles
wget --continue -O osm/contours.mbtiles https://osm.osuv.de/contours.mbtiles
podman run -ti --rm -p 9000:9000 --name sms -v "$(pwd)/osm/:/data/" registry.gitlab.com/markuman/sms:latest
firefox http://localhost:9000

requirements:

  • podman (or docker)
  • 101 GB storage is required (1 core and 512MB memory are sufficient)
  • optional 290 GB storage is required for contour lines

notes:

  • "~30 minutes" depends on your bandwidth ...and the hidrive performance of ionos.

credits

Usage

nextcloud GpxPod

  1. Deploy the container/service behind a webproxy (caddy, nginx, traefik,...you name it.) to get a valid SSL certificate.
  2. Goto GpxPod Settings -> Tile Servers
    • Type: Vector
    • Server address: https://<YOUR_SMS_SERVICE_DEPLOYMENT>/v1/styles/osm-bright-gl-style@1.0.0/style.json?fonts=fonts-gl@1.0.0&tiles=mytiles@1.0.0

URL Parameter

You can add coordinates and Zoomlevel.

https://maps.osuv.de/?lat=48.1374&lng=11.5752&zoom=9

Reverse Geolocation / Photon Integration

  • sms supports photon reverse geocoding server.

Just set -e PHOTONSERVER="https://photon.osuv.de" for your sms Container.

This also enables the geocode and reverse_geocode MCP tools; without it they are not registered at all.

Two consumers, possibly two URLs

PHOTONSERVER is substituted into index.html at startup, so the browser talks to Photon directly with that URL — it has to be publicly reachable.

The MCP tools, on the other hand, call Photon from inside the container. If both run in podman/docker, the public hostname often resolves to a LAN address the container network cannot route to (split-horizon DNS), and you get Connection refused. Changing PHOTONSERVER is not the fix: that would break the frontend.

Use PHOTONSERVER_INTERNAL for the server-side path instead:

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

If PHOTONSERVER_INTERNAL is unset, server-side calls fall back to PHOTONSERVER, which is the right thing when both are reachable from everywhere (single host, no container network in between).

Configuration

Tilesets are configured through numbered environment variable groups (MBTILES__1__*, MBTILES__2__*, …):

VariableRequiredDescription
PORTyesListen port
MBTILES__n__URLyesPath to the .mbtiles file
MBTILES__n__IDENTIFIERyesTileset name used in URLs
MBTILES__n__VERSIONyesTileset version used in URLs
MBTILES__n__MIN_ZOOMyesMinimum zoom level
MBTILES__n__MAX_ZOOMyesMaximum zoom level
HTTP_ACCESS_CONTROL_ALLOW_ORIGINnoCORS header value
PHOTONSERVERnoPublic Photon base URL; enables geocoding and is embedded into the map UI
PHOTONSERVER_INTERNALnoPhoton URL used for server-side calls (MCP tools) when the public one is not reachable from inside the container
TILE_CACHE_SIZEnoDecoded road tiles kept in memory (default 2000; contour and POI caches get a quarter of that each)
ROUTE_MAX_TILESnoCorridor tile limit per segment (default 1200)
ROUTE_MAX_CROW_KMnoStraight-line limit per segment in km (default 50)
GPX_DIRnoWhere generated GPX files are stored (default $TMPDIR/sms-gpx)
GPX_MAX_FILESnoKeep at most this many GPX files (default 200)
GPX_TTL_SECONDSnoDelete GPX files older than this (default 86400)
GPX_MAX_UPLOAD_BYTESnoSize limit for POST /v1/gpx uploads (default 10 MiB)

HELP WANTED

  • Improve Style
    • special for Nextcloud GxpPod
    • provide more style? remove some?

planet.mbtiles

My provided planet.mbtiles is generated by using https://github.com/markuman/planetiler — a fork of planetiler following its PLANET.md tutorial, with one difference: the OpenMapTiles profile is extended to emit outdoor attributes at z14 that the upstream schema drops.

transportation: sac_scale, trail_visibility, via_ferrata_scale, ladder, incline, smoothness, width, tracktype, segregated, mtb_scale_uphill, osm_id

poi: cave entrances, springs, wells, water taps, bivouac huts, saddles, summits, emergency phones, mountain rescue

Without those, sms still works — it simply has no difficulty data to route on or warn about, and the drinking_water/cave/viewpoint/emergency POI categories stay empty. /v1/capabilities reports which attributes the loaded tiles actually carry under difficulty_data.

contours.mbtiles

Contours will be displayed if a file called contours.mbtiles is places next to planet.mbtiles.
There are two different contours files provided. 90m resolution in Zoom Level 11 to 14 and 90m resolution in Zoom Level 11 to 13 with 30m resolution in Zoom Level 14.

GLO-30: "produced using Copernicus WorldDEM-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved"
GLO-90: "produced using Copernicus WorldDEM-90 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved"

Core API

GET /

Serves the map UI frontend (index.html) with optional URL parameters for coordinates and zoom level.

URL Parameters:

  • lat — Latitude coordinate
  • lng — Longitude coordinate
  • zoom — Zoom level

Example: https://maps.osuv.de/?lat=48.1374&lng=11.5752&zoom=9

GET /v1/capabilities

Returns JSON capabilities indicating available optional features.

Response:

{
  "contours":  true,
  "routing":   true,
  "mcp":       true,
  "gpx":       true,
  "geocoding": false,
  "mcp_tools": ["search_poi", "plan_route", "export_gpx"],
  "difficulty_data": {
    "sac_scale": true, "via_ferrata_scale": true,
    "trail_visibility": true, "smoothness": true
  },
  "routing_limits": { "max_tiles": 1200, "max_crow_km": 50.0, "zoom": 14 },
  "tile_cache": {
    "roads":    { "entries": 812, "max_entries": 2000, "hits": 4210, "misses": 812 },
    "contours": { "entries": 240, "max_entries": 500,  "hits": 190,  "misses": 240 },
    "poi":      { "entries": 0,   "max_entries": 500,  "hits": 0,    "misses": 0 }
  }
}
  • contours — whether contours.mbtiles was detected and loaded
  • routing — always true; indicates the /v1/route/ endpoint is available
  • mcp — the MCP endpoint at POST /mcp is available
  • gpx — GPX export is available
  • geocoding — whether PHOTONSERVER is configured (enables the geocode and reverse_geocode MCP tools)
  • mcp_tools — names of the registered MCP tools
  • difficulty_data — which hiking attributes the loaded tiles actually carry. All false on stock OpenMapTiles tiles (see Hiking attributes below), and in that case a missing warning means missing data, not easy terrain. The flags flip to true as soon as a tile containing them has been decoded.
  • sac_scale_values — accepted values for max_sac_scale, easiest first
  • tile_cache — live hit/miss counters of the decoded-tile caches

GET /v1/poi/{identifier}@{version}?lat={lat}&lon={lon}&category={category}&radius={radius}

Search for Points of Interest (POI) within a radius and return GeoJSON features.

Path Parameters:

  • identifier — Tileset identifier
  • version — Tileset version

Query Parameters:

  • lat — Latitude coordinate (required)

  • lon — Longitude coordinate (required)

  • category — POI category (required): supermarket, pharmacy, hospital, fuel, charging_station, alpine_hut, camp_site, shelter, drinking_water, cave, viewpoint, or emergency

    drinking_water, cave, viewpoint and emergency need tiles built with the extended profile linked above — stock OpenMapTiles does not put springs, wells or cave entrances into the poi layer at all. Note that drinking_water includes springs and cattle troughs: that is a location, not a potability guarantee.

    Note that alpine_hut covers only real mountain huts (alpine_hut, wilderness_hut, basic_hut). OpenMapTiles files bus stop shelters and public air-raid shelters under subclass=shelter, which around Garmisch is 113 of 122 hits and mostly unnamed — those live in the separate shelter category so they cannot bury the actual huts.

  • radius — Search radius in km, default 15, max 50 (optional)

Response: GeoJSON FeatureCollection of POIs, each with a properties.distance_km. Sorted by distance, but named POIs come first within the same ~500 m band — an unnamed hut slightly closer is less useful than a named one you can look up.

GET /v1/tiles/{identifier}@{version}/{z}/{x}/{y}.mvt

Fetch a tile in Mapbox Vector Tile (MVT) format.

Path Parameters:

  • identifier — Tileset identifier (e.g., from MBTILES__1__IDENTIFIER)
  • version — Tileset version (e.g., from MBTILES__1__VERSION)
  • z — Zoom level
  • x — Tile column coordinate
  • y — Tile row coordinate (converts from Web Mercator to TMS)

Special: contours@1.0.0 is auto-loaded if contours.mbtiles exists.

Gzip Negotiation: Returns gzip-compressed tiles if client sends Accept-Encoding: gzip.

Caching: max-age=604800 plus an ETag that changes when the .mbtiles file is rebuilt — see Caching. Send If-None-Match to get a cheap 304.

Response: application/vnd.mapbox-vector-tile (MVT format)

GET /v1/styles/{identifier}@{version}/style.json

Returns a MapLibre GL style JSON with injected tile, font, and sprite URLs.

Path Parameters:

  • identifier — Style identifier (e.g., osm-bright-gl-style)
  • version — Style version (e.g., 1.0.0)

Query Parameters:

  • tiles — Tile source in format {tile_id}@{tile_version} (required)
  • fonts — Font source in format {font_id}@{font_version} (required)

Available Styles:

  • dark-matter-gl-style@1.0.0
  • fiord-color-gl-style@1.0.0
  • maptiler-3d-gl-style@1.0.0
  • maptiler-terrain-gl-style@1.0.0
  • maptiler-basic-gl-style@1.0.0
  • maptiler-toner-gl-style@1.0.0
  • osm-bright-gl-style@1.0.0
  • positron-gl-style@1.0.0
  • osuv-style@1.0.0

Response: application/json with complete style JSON including:

  • Tile source URL: /v1/tiles/{tiles}@{version}/{z}/{x}/{y}.mvt
  • Glyph URL: /v1/fonts/{fonts}@{version}/{fontstack}/{range}.pbf
  • Sprite URL: /v1/styles/{identifier}@{version}/sprite
  • Contour layers (if contours.mbtiles detected): contour-line and contour-label (visible when toggled in frontend)

GET /v1/styles/{identifier}@{version}/sprite.json

Returns the sprite JSON index file.

Path Parameters:

  • identifier — Style identifier
  • version — Style version

Response: application/json with sprite image references and bounds

GET /v1/styles/{identifier}@{version}/sprite@2x.json

Returns the high-resolution (2x) sprite JSON index file.

Path Parameters:

  • identifier — Style identifier
  • version — Style version

Response: application/json with high-res sprite references

GET /v1/styles/{identifier}@{version}/sprite.png

Returns the sprite image (1x resolution).

Path Parameters:

  • identifier — Style identifier
  • version — Style version

Response: image/png

GET /v1/styles/{identifier}@{version}/sprite@2x.png

Returns the sprite image (2x resolution for retina displays).

Path Parameters:

  • identifier — Style identifier
  • version — Style version

Response: image/png

GET /v1/fonts/{identifier}@{version}/{stack}/{range}.pbf

Returns merged glyph data in Protocol Buffer format for font rendering.

Path Parameters:

  • identifier — Font identifier (fonts-gl)
  • version — Font version (1.0.0)
  • stack — Comma-separated font stack (max 4 fonts, e.g., Noto Sans Regular,Noto Sans Bold)
  • range — Glyph range (e.g., 0-255)

Response: application/vnd.google.protobuf

  • Gzip-compressed if client sends Accept-Encoding: gzip
  • Merges glyphs from all fonts in stack, deduplicating by glyph ID

GET /v1/route/{identifier}@{version}?from={lat},{lon}&to={lat},{lon}&profile={profile}

Compute a walking or cycling route between two coordinates using the vector tile road network. No external routing engine required — routing is performed entirely server-side from the MBTiles data.

Path Parameters:

  • identifier — Tileset identifier (same as used for /v1/tiles/)
  • version — Tileset version

Query Parameters:

  • from — Start point as lat,lon (required)

  • to — End point as lat,lon (required)

  • profile — Routing profile: foot (default) or bike

  • buffer_km — Corridor half-width around the straight line (optional). Defaults to 10 % of the segment length, at least ~3.3 km. Raise it when a detour around a lake or a closed area is needed.

  • elevation — true to add ascent_m/descent_m from contours.mbtiles and switch the duration estimate to DIN 33466 (optional)

  • max_sac_scale — hardest SAC grade allowed: hiking (T1), mountain_hiking (T2), demanding_mountain_hiking (T3), alpine_hiking (T4), demanding_alpine_hiking (T5), difficult_alpine_hiking (T6). Ways tagged above the limit are excluded from routing. Requires tiles with hiking attributes (optional)

  • allow_via_ferrata — false excludes ways tagged as via ferrata or with fixed ladders (optional, default true)

  • prefer_routes — true prefers ways carrying a marked hiking route (iwn/nwn/rwn/lwn), or a cycle route for profile=bike (optional)

  • follow_route — follow one named route, e.g. Malerweg or the ref E3 (optional)

    Note these are independent axes: a way can be T6 scrambling without any ferrata tag, and a cabled route can be tagged T2. On a real Zugspitze test, excluding ferratas alone still produced a T6 route — for a safe tour set both. The response says so via terrain_warnings when only one is used.

Example:

GET /v1/route/mytiles@1.0.0?from=48.137,11.575&to=48.155,11.602&profile=foot

Response: GeoJSON Feature with LineString geometry

{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [[11.575, 48.137], ...]
  },
  "properties": {
    "distance_km":  3.241,
    "duration_min": 43.2,
    "profile":      "foot",
    "tiles_loaded": 36,
    "buffer_tiles": 2.0,
    "nodes":        12847,
    "cache_hits":   30,
    "cache_misses": 6,
    "snap_start_m": 12.4,
    "snap_end_m":   31.9
  }
}

With hiking attributes in the tiles, properties additionally carries:

{
  "max_sac_scale": "T3",
  "sac_scale": { "T1": "hiking", "T2": "mountain_hiking",
                 "T3": "demanding_mountain_hiking" },
  "surface_segments": { "unpaved": 788, "paved": 16 },
  "via_ferrata_sections": 36,
  "routes": [
    { "network": "rwn", "network_label": "regional",
      "name": "Malerweg", "segments": 68, "share_percent": 28 }
  ],
  "terrain_warnings": [
    "hardest section on this route is T3 = demanding_mountain_hiking near ...",
    "trail_visibility=horrible near 47.08226,9.61025 -- the path may be hard ..."
  ]
}

sac_scale_data appears instead of sac_scale when nothing along the route is tagged — that is a reminder that the difficulty is unknown, not that it is easy.

Profile weights (OpenMapTiles transportation layer class attribute):

Road classfootbike
footway, path, pedestrianpreferred (1.0)not passable
cyclewayallowed (1.3)preferred (1.0)
track1.11.2
residential, living_street1.2–1.31.1
tertiary1.81.4
secondary2.51.6
primarynot passable2.5
motorway, trunknot passablenot passable
steps1.2not passable

Duration estimate: 4.5 km/h for foot, 15 km/h for bike. With elevation=true and contours.mbtiles present, foot switches to a DIN 33466 / SAC estimate instead (300 m ascent or 500 m descent per hour, combined as max(horizontal, vertical) + min(horizontal, vertical) / 2).

Hiking attributes: sac_scale, via ferratas, surface

OpenMapTiles is a rendering schema. Its transportation layer whitelists the attributes it keeps, and sac_scale, trail_visibility and via_ferrata_scale are not on that list — they exist in the OSM source data but are dropped during tile generation. The practical consequence is severe: a router cannot tell a T1 stroll from a T5 scramble, and via ferratas appear as ordinary paths. A route from the Zugspitze to the Alpspitze happily runs over "Stopselzieher" and "Höllentalsteig", both cabled climbing routes with ladders and a glacier crossing.

sms therefore evaluates these attributes when they are present:

AttributeUsed for
sac_scalemax_sac_scale filter, terrain_warnings, extra time for T3+ on foot
via_ferrata_scale, ladderallow_via_ferrata=false filter, warnings
trail_visibilitywarning at bad/horrible/no
smoothness, tracktypeweighting for bike — decides riding vs. pushing
mtb_scalewarning at 4+
surfacestatistics, minor weighting
osm_idwarnings link to the way on openstreetmap.org, and group repeated sections

Where a way carries a real grade, the name-based fallback heuristic ("...steig", "Ferrata", "Grat") is suppressed — the "Höllentalsteig" near Garmisch is tagged T2, so warning about its name would contradict the data. The heuristic stays active on untagged ways.

To get them, the tiles must be built with a planetiler whose OpenMapTiles profile emits those fields — see markuman/planetiler, which adds them at z14. /v1/capabilities reports under difficulty_data whether the loaded tiles carry them.

Without such tiles everything still works, just without difficulty data: untagged ways are never excluded and never penalised, so routing behaves exactly as before. That is deliberate — most of the world has no sac_scale, and excluding untagged ways would produce empty results while pretending to be safe. An untagged path can be anything.

Marked hiking routes

OpenMapTiles flattens route relations into route_1_* … route_4_* on the transportation_name layer, so the tiles know which ways carry the E3, the Malerweg, a Via Alpina stage or a local marked trail. A way on a marked route is signposted, maintained and usually the scenic line, so prefer_routes=true gives those a weight discount, ordered by network importance:

NetworkMeaningFactor
iwn / icninternational0.70
nwn / ncnnational0.72
rwn / rcnregional0.78
lwn / lcnlocal0.85

Road route relations (DE:national, cz:regional, e-road) are not weighted — those are road numbers, not trails.

follow_route=Forststeig pulls much harder (factor 0.25) towards one specific route. This exists because plain waypoint routing cuts the loops a marked trail makes: on the Forststeig Elbsandstein the rock-group detours (Müllerstein, Rotstein, Spanghorn) are skipped unless every one of them is a waypoint. Measured on that trail:

DistanceRoutes followed
plain13.11 km—
prefer_routes13.11 kmGelber Balken 49 %, Roter Punkt 32 %
follow_route=Forststeig14.83 kmForststeig Elbsandstein 28 %

Both are weightings, not filters: unmarked ways stay available, so a region without marked routes still routes normally. follow_route returns a 404 if no such route exists in the corridor, and a terrain_warnings entry if the route exists but does not connect the two points.

The response lists the routes actually used under properties.routes with the share of the track:

"routes": [
  { "network": "rwn", "network_label": "regional",
    "name": "Forststeig Elbsandstein", "segments": 68, "share_percent": 28 }
]

Route geometry lives in its own generalised layer, so routes are matched to graph edges by proximity (~200 m grid on the segment midpoints).

Tile selection: corridor instead of bounding box

Tiles are loaded as a corridor along the straight line between the two points, not as a bounding box. At 48° N a zoom-14 tile is about 1.64 km wide, so a bounding box for a diagonal 50 km route covers ~1260 tiles of which most are nowhere near the route. The corridor keeps only tiles within buffer_km of the line:

RouteCorridorOld bounding boxSaving
30 km diagonal112 tiles484 tiles4.3x
50 km diagonal276 tiles1260 tiles4.6x
50 km at 15°239 tiles598 tiles2.5x
50 km axis-parallel99 tiles100 tiles1.0x

For an almost axis-parallel route the bounding box is naturally narrow already, so the default buffer is capped at the box's short side and the corridor never ends up loading materially more than a bbox would.

Limits:

  • max_tiles: 1200 tiles at zoom 14 (OOM guard, configurable via ROUTE_MAX_TILES)
  • max_crow_km: 50 km straight-line distance per segment (configurable via ROUTE_MAX_CROW_KM). Longer tours are possible via the plan_route MCP tool, which chains segments.
  • Start and end points must be within 500 m of a routable road. Both ends are snapped onto the same connected part of the network — vector tiles are clipped at tile borders, so the decoded graph contains many short disconnected stubs that would otherwise swallow the start point.
  • No turn restrictions and no access tags (OSM relations and access=private are not stored in vector tiles)
  • Routing always uses zoom 14. There is deliberately no zoom-13 fallback: path and footway have minzoom 14 in OpenMapTiles, so on z13 exactly the ways that matter for hiking disappear.
  • Decoded tiles are cached (see TILE_CACHE_SIZE), so repeated queries in the same region are much faster than the first one. Expect ~0.2–2 s cold and well under 0.5 s warm for a 50 km segment. Memory stays within the documented 512 MB.

Error responses:

  • 400 — invalid parameters, straight-line distance over the limit, or corridor over max_tiles
  • 404 — no road network in area, no way passable for the profile, start or end further than 500 m from a road, or no route found

POST /v1/elevation/{identifier}@{version}

Estimate ascent and descent of a track by intersecting it with the contour lines from contours.mbtiles. Only available when that file is present.

Body:

{ "coordinates": [[11.575, 48.137], [11.602, 48.155]] }

Coordinates are [lon, lat] pairs (GeoJSON order).

Response:

{ "ascent_m": 640, "descent_m": 210 }

Accuracy is limited by the contour interval (about ±half an interval), so treat these as estimates, not survey data.

Error responses:

  • 400 — invalid body or area too large
  • 404 — no contours.mbtiles loaded

GET /v1/gpx/{id}.gpx

Download a GPX file previously generated by the plan_route or export_gpx MCP tool. The id is the gpx_id from the tool result.

Response: application/gpx+xml (GPX 1.1, with <wpt> waypoints and <ele> values when contours were available)

Generated files are cleaned up automatically: anything older than GPX_TTL_SECONDS is removed, and at most GPX_MAX_FILES are kept.

Error responses:

  • 404 — unknown or expired id

POST /v1/gpx

Upload a GPX file and get a gpx_id back — the input side of the analyze_gpx MCP tool. The body is either the raw document (application/gpx+xml, text/xml, anything) or a multipart form with a file field. GPX 1.0 and 1.1 are accepted, <trk> segments are concatenated, <rte> is used when there is no track. Files with a DOCTYPE or entity declarations are rejected (no defusedxml dependency, so no entity expansion at all).

curl -s -X POST --data-binary @tour.gpx localhost:9000/v1/gpx
{
  "gpx_id": "5c925fc1584040a4",
  "gpx_url": "http://localhost:9000/v1/gpx/5c925fc1584040a4.gpx",
  "name": "Karwendel-Durchquerung",
  "points": 4812,
  "waypoints": 3,
  "has_elevation": true
}

The file is stored verbatim (download is byte-identical) and subject to the same TTL / max-files housekeeping as generated tracks.

Error responses:

  • 400 — not a usable GPX (malformed, fewer than two points, DOCTYPE)
  • 413 — larger than GPX_MAX_UPLOAD_BYTES

GET /v1/gpx/{id}/analysis

REST twin of the analyze_gpx MCP tool (see below). Query parameters: profile, poi_categories (comma separated), poi_max_off_km, elevation_source, tileset.

Map UI routing options

The map UI exposes the same knobs: a transport dropdown (hiking / bike), a difficulty dropdown (T1–T6, default "egal"/any) and an "ohne Klettersteig" checkbox which is on by default — a hiking UI should not silently route over a via ferrata — plus a "markierte Wege" checkbox (also on by default) for prefer_routes. The names of the marked trails the route follows are shown below the toolbar. Changing any of them recalculates all segments, and terrain_warnings are shown below the toolbar with the OSM way linked.

Caching

Tiles are the hot path — a single pan fetches dozens of them — so everything that can be cached is cached, and everything dynamic explicitly is not:

EndpointCache-ControlWhy
/v1/tiles/…public, max-age=604800, stale-while-revalidate=86400immutable per dataset, ETag catches rebuilds
/v1/tiles/… (404)public, max-age=3600ocean and out-of-extract tiles 404 on every pan
/v1/static/…, /v1/fonts/…, spritespublic, max-age=31536000, immutableversion is part of the URL
/v1/styles/…/style.jsonpublic, max-age=3600embeds request-derived URLs
/v1/gpx/<id>.gpxprivate, max-age=86400stable per id, but user generated
/no-cacherewritten by startup.sh, revalidates via ETag
/v1/route, /v1/poi, /v1/gpx/<id>/analysis, /v1/capabilities, /mcpno-storea stale route is worse than a recomputed one

Tile ETags are <dataset>-<z>-<x>-<y>, where dataset is a fingerprint of the .mbtiles file (mtime and size). Rebuilding the dataset therefore invalidates every cached tile automatically — no version to bump, no cache to purge. A revalidation costs one conditional request and returns 304 with an empty body; measured over 20 tiles: 250 kB and 763 ms down to 0 kB and 9 ms, and within max-age the browser does not even ask.

The ETag is checked before touching SQLite, so a revalidation is answered without a database read.

MCP API (route planning for LLM agents)

sms exposes a Model Context Protocol endpoint so that an LLM agent can plan hiking and trekking tours and export them as GPX — without an external routing engine and without any additional data source.

POST /mcp

Stateless JSON-RPC 2.0 (streamable HTTP without SSE). Implemented methods: initialize, notifications/*, ping, tools/list, tools/call. GET /mcp returns 405 — there is no event stream.

Register it with an MCP client, for example in opencode.json:

{
  "mcp": {
    "sms": {
      "type": "remote",
      "url": "https://maps.example.org/mcp",
      "enabled": true
    }
  }
}

Or check it manually:

curl -s localhost:9000/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq

Tools

ToolPurposeRequires
geocodeplace name / address → coordinatesPHOTONSERVER
reverse_geocodecoordinates → nearest addressPHOTONSERVER
search_poihuts, water, resupply around a point—
plan_routemulti-waypoint tour + GPX export—
export_gpxwrite arbitrary coordinates as GPX—
analyze_gpxassess an uploaded GPX track: ways, difficulty, elevation, POIs along it—

plan_route takes waypoints: [[lat, lon], ...] and routes each consecutive pair separately. Each single segment must stay under max_crow_km, but the total tour is unlimited — three 45 km segments give a 135 km track. If a segment fails, the remaining segments are still returned and the failure is reported per segment ("segment 2 (48.15,11.57 -> ...): no routable way within 500 m ..."), so the agent can fix that one waypoint instead of guessing blindly.

Besides waypoints, profile and buffer_km, plan_route accepts the same knobs as the REST endpoint:

ArgumentPurpose
max_sac_scaleexclude terrain above a SAC grade
allow_via_ferratafalse keeps cabled climbing routes out
prefer_routesprefer signposted trails
follow_routefollow one named trail, e.g. "Malerweg" or "E3"
elevationascent/descent from contours.mbtiles
export_gpxwrite the GPX file (default true)
nametrack name stored in the GPX file
tilesetpick a tileset as identifier@version when several are configured

The track geometry is deliberately not part of the tool result — a few thousand track points as text would flood the model's context window. The tool returns metadata plus a URL:

{
  "name": "Karwendel-Durchquerung",
  "distance_km": 42.1,
  "duration_min": 561,
  "ascent_m": 1840,
  "descent_m": 1620,
  "points": 3000,
  "max_sac_scale": "T3",
  "routes": [
    { "network": "iwn", "network_label": "international",
      "ref": "E4", "name": "European long distance path E4",
      "segments": 412, "share_percent": 41 }
  ],
  "terrain_warnings": [
    "hardest section on this route is T3 = demanding_mountain_hiking near 47.42107,11.06653; ..."
  ],
  "gpx_url": "https://maps.example.org/v1/gpx/5c925fc1584040a4.gpx"
}

analyze_gpx — assessing an existing track

The reverse direction: the user already has a GPX (from a tour portal, a friend, last year's recording) and wants to know what they are getting into. Upload it with POST /v1/gpx, then call analyze_gpx with the gpx_id. The ids returned by plan_route and export_gpx work as well, so an agent can plan a tour and immediately check it for water and huts along the way.

The track is map-matched onto the tile network: it is resampled every 20 m and each sample is snapped to the nearest transportation segment within 30 m. From the matched segments the tool reports way classes, surfaces, tagged difficulty and marked routes — everything plan_route reports, plus the km position on the track ("VIA FERRATA at km 12.0 (…)"). Samples with no way within 30 m count as off-network: pathless terrain (alpine crossings, glaciers, fords) or ways missing in the tiles — either way the agent cannot say anything about those sections and is told so.

POIs are collected along the corridor with their position on the track and distance off it. poi_summary gives, per category, the count and the longest gap — "16 km without drinking water" is the number that decides how much to carry.

{
  "name": "Forststeig Etappe 3",
  "distance_km": 18.4,
  "duration_min": 372,
  "elevation_source": "gpx",
  "ascent_m": 640, "descent_m": 710, "min_ele_m": 312, "max_ele_m": 561,
  "matched_percent": 94,
  "off_network_km": 1.1,
  "off_network_sections": [{ "from_km": 7.2, "to_km": 8.1, "length_km": 0.9 }],
  "road_classes_km": { "path": 12.3, "track": 4.9, "minor": 1.2 },
  "surface_km": { "ground": 6.1, "gravel": 3.2 },
  "max_sac_scale": "T2",
  "routes": [{ "network": "rwn", "network_label": "regional", "name": "Forststeig", "share_percent": 71 }],
  "terrain_warnings": ["hardest section on this route is T2 = mountain_hiking at km 9.6 (…)"],
  "poi_summary": {
    "drinking_water": { "count": 2, "max_gap_km": 11.3 },
    "shelter": { "count": 5, "max_gap_km": 6.0 },
    "alpine_hut": { "count": 0, "max_gap_km": 18.4 }
  },
  "pois": [
    { "name": "Taubenteich", "category": "drinking_water", "at_km": 3.1, "off_track_m": 40, "lat": 50.8794, "lon": 14.1201 }
  ]
}
ArgumentPurpose
gpx_idfrom POST /v1/gpx, plan_route or export_gpx (required)
profilefoot / bike, for the duration estimate
poi_categoriesdefault alpine_hut, shelter, camp_site, drinking_water, supermarket, emergency
poi_max_off_kmcorridor half-width for POIs (default 1, max 5)
elevation_sourceauto (track <ele> when ≥ 90 % of points have it, else contours), gpx, contours
tilesetidentifier@version

Elevation from the track's own <ele> is smoothed with a 10 m hysteresis; barometric recordings are good, plain GPS elevation still tends to overstate. Pass elevation_source: "contours" to cross-check.

What the agent can and cannot know

With tiles built from the extended profile, difficulty is available where OSM contributors tagged it: plan_route accepts max_sac_scale and allow_via_ferrata, and returns terrain_warnings, max_sac_scale and the marked trails under routes. Agents are told to pass those on verbatim.

What still cannot be known, and what the tool descriptions say explicitly:

  • Untagged ways are unknown, not easy. They are never excluded and never penalised (see above for why), so a route may still contain a T5 scramble that nobody tagged. A missing warning means missing data.
  • Via ferratas are ordinary paths in the data. Where no via_ferrata_scale exists, only a name heuristic ("…steig", "Ferrata", "Grat") is left — it produces false positives and misses unnamed ways.
  • max_sac_scale and allow_via_ferrata are independent axes. A way can be T6 scrambling without a ferrata tag, and a cabled route can be tagged T2. Set both.
  • Not in the tiles at all: seasonal closures, wildlife sanctuaries with right-of-way restrictions, access=private, turn restrictions, hut opening hours, prices and whether a hut is staffed.
  • On stock OpenMapTiles tiles none of the difficulty data exists. /v1/capabilities reports this under difficulty_data; when those flags are false, the agent is flying blind and should say so.

Never treat a generated track as a safety assessment — cross-check it against a topographic map before walking it.

GET /v1/static/{identifier}@{version}/{file}

Serves vendored MapLibre GL JS/CSS static assets.

Path Parameters:

  • identifier — Asset identifier (maplibre-gl)
  • version — Asset version (5.19.0)
  • file — File name: maplibre-gl.css or maplibre-gl.js

Response: CSS or JavaScript file

For the curious, advanced, or developers of this server itself

Hosting your own vector map tiles to show them in a browser requires quite a few components:

  1. JavaScript and CSS

    A Javascript and CSS library, such as MapLibre GL, and your own code to run this library, pointing it to a style file

  2. Style file

    A JSON file that defines how the library should visually style the map data, and where it should find the map tiles, glyphs (fonts), and the sprite. This server transforms the built-in Style files on the fly to be able to refer to any map data.

  3. Glyphs (fonts)

    Different fonts can be used for different labels and zoom levels, as defined in the Style file. The fonts must Signed Distance Field (SDF) fonts wrapped in a particular Protocol Buffer format. The Style file can refer to "stacks" of fonts; but unlike CSS, the server combines the fonts on the fly in an API where the resulting "font" has at most one glyph from each source font.

  4. Sprite

    A sprite is actually 4 URLs: a JSON index file and a single PNG file, and a "@2x" JSON index file and PNG files for higher pixel ratio devices (e.g. Retina). The JSON files contains the offsets and sizes of images within corresponding PNG file. The style file refers the common "base" of these. For example, if the style file has "sprite":"https://my.test/sprite" then the 4 files must be at https://my.test/sprite.json, https://my.test/sprite.png, https://my.test/sprite@2x.json and https://my.test/sprite@2x.png.

  5. Vector map tiles

    A set of often millions of tiles each covering a different location and different zoom level. These can be distributed as a single mbtiles file, but this is not the format that the Javascript library accepts. This on-the-fly conversion from the mbtiles file to tiles is the main feature of this server.

    The mbtiles file is a SQLite file, containing gzipped Mapbox Vector Tile tiles. This server leaves the un-gzipping to the browser, by sending tiles with a content-encoding: gzip header, which results in browser un-gzipping the tile data before it hits the Javascript.

Licenses

The code of the server itself is released under the MIT license. However, several components included in mbtiles_s3_server/vendor/ are released under different licenses.

support

  • My Project: https://github.com/markuman/sms
  • For the large planet.mbtiles generation and hosting, donations are welcome 🙂
    • paypal.me/MarkusBergholz
    • bc1qz33cf70vq82gxf8kps06j7lv7m2903hsnjak6k

Collected info

  • ★ 270 stars
  • ⎇ 10 forks
  • Language: Python
  • Source updated: 9/24/2026

Config for your environment

Replace {MCP_ENDPOINT_URL} with this MCP’s endpoint URL (from its repo or docs above). No API key — you connect directly.

Tool

OS

Config file: ~/.cursor/mcp.json

{
  "mcpServers": {
    "mcp-server": {
      "url": "{MCP_ENDPOINT_URL}"
    }
  }
}

Paste into mcpServers in the config file. Restart Cursor after saving.

If this MCP is also published on mcpchannel.ai, you can subscribe from Browse and use the gateway config there instead.