Real-time global intelligence dashboard. AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface
MCP server for MBTiles map tiles and routing
This project serves `.mbtiles` files as map tiles, styles, sprites, fonts, POI search, routing, elevation, and GPX downloads. When Photon is configured, it also adds geocoding tools through MCP, so an agent can look up places and plan routes from the same service.
Builders who want an agent to query map tiles, plan walking or cycling routes, and export GPX tracks from local OSM data.
You can give your agent a local map and routing backend instead of re-explaining geography or exporting tracks by hand.
What it does
Serve MBTiles tiles
Returns Mapbox Vector Tiles from configured `.mbtiles` sources at `/v1/tiles/{identifier}@{version}/{z}/{x}/{y}.mvt`.
Expose MCP tools
Registers tools such as `search_poi`, `plan_route`, `export_gpx`, and optionally `geocode` and `reverse_geocode`.
Route from vector tiles
Computes foot and bike routes directly from the tile data, with options for route preference, ferrata filtering, and elevation estimates.
Export GPX tracks
Creates GPX files from planned routes and serves them later at `/v1/gpx/{id}.gpx`.
Serve map styles and glyphs
Returns MapLibre style JSON, sprites, and glyph PBFs for the bundled map UI and external clients.
Search POIs and reverse geocode
Finds nearby points of interest and can call Photon for forward and reverse geocoding when configured.
How to get it
- 1Run
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
- 2Example
GET /v1/route/mytiles@1.0.0?from=48.137,11.575&to=48.155,11.602&profile=foot
- 3Or check it manually
curl -s localhost:9000/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
README
πΊοΈ OSM - self host the entire planet π in ~30 minutes π
simple mbtiles server
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(ordocker)- 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
- https://github.com/onthegomap/planetiler is used to generate the planet.mbtiles file
- https://github.com/uktrade/mbtiles-s3-server is the origin code-base of my
smsproject
Usage
nextcloud GpxPod
- Deploy the container/service behind a webproxy (caddy, nginx, traefik,...you name it.) to get a valid SSL certificate.
- 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__*, β¦):
| Variable | Required | Description |
|---|---|---|
PORT | yes | Listen port |
MBTILES__n__URL | yes | Path to the .mbtiles file |
MBTILES__n__IDENTIFIER | yes | Tileset name used in URLs |
MBTILES__n__VERSION | yes | Tileset version used in URLs |
MBTILES__n__MIN_ZOOM | yes | Minimum zoom level |
MBTILES__n__MAX_ZOOM | yes | Maximum zoom level |
HTTP_ACCESS_CONTROL_ALLOW_ORIGIN | no | CORS header value |
PHOTONSERVER | no | Public Photon base URL; enables geocoding and is embedded into the map UI |
PHOTONSERVER_INTERNAL | no | Photon URL used for server-side calls (MCP tools) when the public one is not reachable from inside the container |
TILE_CACHE_SIZE | no | Decoded road tiles kept in memory (default 2000; contour and POI caches get a quarter of that each) |
ROUTE_MAX_TILES | no | Corridor tile limit per segment (default 1200) |
ROUTE_MAX_CROW_KM | no | Straight-line limit per segment in km (default 50) |
GPX_DIR | no | Where generated GPX files are stored (default $TMPDIR/sms-gpx) |
GPX_MAX_FILES | no | Keep at most this many GPX files (default 200) |
GPX_TTL_SECONDS | no | Delete GPX files older than this (default 86400) |
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 coordinatelngβ Longitude coordinatezoomβ 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β whethercontours.mbtileswas detected and loadedroutingβ alwaystrue; indicates the/v1/route/endpoint is availablemcpβ the MCP endpoint atPOST /mcpis availablegpxβ GPX export is availablegeocodingβ whetherPHOTONSERVERis configured (enables thegeocodeandreverse_geocodeMCP tools)mcp_toolsβ names of the registered MCP toolsdifficulty_dataβ which hiking attributes the loaded tiles actually carry. Allfalseon stock OpenMapTiles tiles (see Hiking attributes below), and in that case a missing warning means missing data, not easy terrain. The flags flip totrueas soon as a tile containing them has been decoded.sac_scale_valuesβ accepted values formax_sac_scale, easiest firsttile_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 identifierversionβ 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, oremergencydrinking_water,cave,viewpointandemergencyneed tiles built with the extended profile linked above β stock OpenMapTiles does not put springs, wells or cave entrances into thepoilayer at all. Note thatdrinking_waterincludes springs and cattle troughs: that is a location, not a potability guarantee.Note that
alpine_hutcovers only real mountain huts (alpine_hut,wilderness_hut,basic_hut). OpenMapTiles files bus stop shelters and public air-raid shelters undersubclass=shelter, which around Garmisch is 113 of 122 hits and mostly unnamed β those live in the separatesheltercategory 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., fromMBTILES__1__IDENTIFIER)versionβ Tileset version (e.g., fromMBTILES__1__VERSION)zβ Zoom levelxβ Tile column coordinateyβ 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.0fiord-color-gl-style@1.0.0maptiler-3d-gl-style@1.0.0maptiler-terrain-gl-style@1.0.0maptiler-basic-gl-style@1.0.0maptiler-toner-gl-style@1.0.0osm-bright-gl-style@1.0.0positron-gl-style@1.0.0osuv-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-lineandcontour-label(visible when toggled in frontend)
GET /v1/styles/{identifier}@{version}/sprite.json
Returns the sprite JSON index file.
Path Parameters:
identifierβ Style identifierversionβ 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 identifierversionβ 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 identifierversionβ 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 identifierversionβ 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 aslat,lon(required) -
toβ End point aslat,lon(required) -
profileβ Routing profile:foot(default) orbike -
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βtrueto addascent_m/descent_mfromcontours.mbtilesand 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βfalseexcludes ways tagged as via ferrata or with fixed ladders (optional, defaulttrue) -
prefer_routesβtrueprefers ways carrying a marked hiking route (iwn/nwn/rwn/lwn), or a cycle route forprofile=bike(optional) -
follow_routeβ follow one named route, e.g.Malerwegor the refE3(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_warningswhen 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 class | foot | bike |
|---|---|---|
footway, path, pedestrian | preferred (1.0) | not passable |
cycleway | allowed (1.3) | preferred (1.0) |
track | 1.1 | 1.2 |
residential, living_street | 1.2β1.3 | 1.1 |
tertiary | 1.8 | 1.4 |
secondary | 2.5 | 1.6 |
primary | not passable | 2.5 |
motorway, trunk | not passable | not passable |
steps | 1.2 | not 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:
| Attribute | Used for |
|---|---|
sac_scale | max_sac_scale filter, terrain_warnings, extra time for T3+ on foot |
via_ferrata_scale, ladder | allow_via_ferrata=false filter, warnings |
trail_visibility | warning at bad/horrible/no |
smoothness, tracktype | weighting for bike β decides riding vs. pushing |
mtb_scale | warning at 4+ |
surface | statistics, minor weighting |
osm_id | warnings 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:
| Network | Meaning | Factor |
|---|---|---|
iwn / icn | international | 0.70 |
nwn / ncn | national | 0.72 |
rwn / rcn | regional | 0.78 |
lwn / lcn | local | 0.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:
| Distance | Routes followed | |
|---|---|---|
| plain | 13.11 km | β |
prefer_routes | 13.11 km | Gelber Balken 49 %, Roter Punkt 32 % |
follow_route=Forststeig | 14.83 km | Forststeig 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:
| Route | Corridor | Old bounding box | Saving |
|---|---|---|---|
| 30 km diagonal | 112 tiles | 484 tiles | 4.3x |
| 50 km diagonal | 276 tiles | 1260 tiles | 4.6x |
| 50 km at 15Β° | 239 tiles | 598 tiles | 2.5x |
| 50 km axis-parallel | 99 tiles | 100 tiles | 1.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 viaROUTE_MAX_TILES)max_crow_km: 50 km straight-line distance per segment (configurable viaROUTE_MAX_CROW_KM). Longer tours are possible via theplan_routeMCP 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=privateare not stored in vector tiles) - Routing always uses zoom 14. There is deliberately no zoom-13 fallback:
pathandfootwayhave 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 overmax_tiles404β 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 large404β nocontours.mbtilesloaded
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
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:
| Endpoint | Cache-Control | Why |
|---|---|---|
/v1/tiles/β¦ | public, max-age=604800, stale-while-revalidate=86400 | immutable per dataset, ETag catches rebuilds |
/v1/tiles/β¦ (404) | public, max-age=3600 | ocean and out-of-extract tiles 404 on every pan |
/v1/static/β¦, /v1/fonts/β¦, sprites | public, max-age=31536000, immutable | version is part of the URL |
/v1/styles/β¦/style.json | public, max-age=3600 | embeds request-derived URLs |
/v1/gpx/<id>.gpx | private, max-age=86400 | stable per id, but user generated |
/ | no-cache | rewritten by startup.sh, revalidates via ETag |
/v1/route, /v1/poi, /v1/capabilities, /mcp | no-store | a 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
| Tool | Purpose | Requires |
|---|---|---|
geocode | place name / address β coordinates | PHOTONSERVER |
reverse_geocode | coordinates β nearest address | PHOTONSERVER |
search_poi | huts, water, resupply around a point | β |
plan_route | multi-waypoint tour + GPX export | β |
export_gpx | write arbitrary coordinates as GPX | β |
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:
| Argument | Purpose |
|---|---|
max_sac_scale | exclude terrain above a SAC grade |
allow_via_ferrata | false keeps cabled climbing routes out |
prefer_routes | prefer signposted trails |
follow_route | follow one named trail, e.g. "Malerweg" or "E3" |
elevation | ascent/descent from contours.mbtiles |
export_gpx | write the GPX file (default true) |
name | track name stored in the GPX file |
tileset | pick 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"
}
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_scaleexists, only a name heuristic ("β¦steig", "Ferrata", "Grat") is left β it produces false positives and misses unnamed ways. max_sac_scaleandallow_via_ferrataare 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/capabilitiesreports this underdifficulty_data; when those flags arefalse, 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.cssormaplibre-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:
-
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
-
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.
-
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.
-
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 athttps://my.test/sprite.json,https://my.test/sprite.png,https://my.test/sprite@2x.jsonandhttps://my.test/sprite@2x.png. -
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: gzipheader, 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
Files in the repo
- simple_mbtiles_server
- test
- .gitignore
- Dockerfile
- example.html
- LICENSE
- MANIFEST.in
- map.sh
- README.md
- requirements.txt
- setup.py
- startup.sh
Discussion (0)
Ask about usage, or say what you built with itSign in to join the discussion.
No comments yet. Be the first to say what this is good for.
More connectors
High-performance code intelligence MCP server. Indexes codebases into a persistent knowledge graph β average repo in milliseconds. 158 languages, sub-ms queries, 99% fewer tokens. Single static binary, zero dependencies.

Universal provider proxy for OpenAI Codex & Claude Code β use any LLM (Claude, Gemini, Grok, DeepSeek, Ollamaβ¦) with Codex CLI, App, SDK, and Claude Code
Git-native persistent memory for AI coding agents. Implements Google OKF v0.2 with sub-300Β΅s in-memory BM25 search, embedded MCP server, and progressive disclosure. Slashes token bloat by 80% with zero external databases or dependencies. Built in pure Go.
Local-first code intelligence graph for MCP and CLI. Builds a persistent map of your codebase so AI coding tools read only what matters, with benchmarked context reductions on reviews and large-repo workflows.
Stop your AI from making things up β it proposes, deterministic tools decide, every claim checked against ground truth with evidence. Grounded facts and context survive resets. Reverse engineering is the proving ground. MCP server + CLI.