Skip to content
Documentation menu
docs / mcp · raw .md
Wire an agent to MapMap in 2 minutes · 2:20 · all videos

MCP server

sn-mcp makes the platform agent-consumable: any MCP client (Claude Code, Claude Desktop, Cursor, Codex, VS Code, or your own agent) gets routing, dangerous-goods compliance, geocoding, matrix, elevation, optimisation, along-route search, day planning, reachability, place verification, map correction, integration feedback, local geometry and map-styling tools (fifty-two in total) with full JSON Schemas and structured outputs.

There are two ways in: connect to the hosted endpoint (nothing to install), or run the server yourself against a self-hosted deployment. Both are covered below.

Connect your agent (hosted, no install)

The hosted MCP server speaks streamable HTTP at:

arduino
https://mcp.mapmap.ai/mcp

Status, honestly: the endpoint is live and open for evaluation: no key needed to connect, fair use applies (rate limited per client IP; over the limit you get 429 with Retry-After). Every tool below is registered on the hosted endpoint, and the routing, geocoding, optimisation, place and local-geometry tools all answer there today.

You can also bill metered calls to your own gateway key: present your snk_ key as Authorization: Bearer snk_… on this endpoint and every metered call you make meters and bills to that key rather than to ours. That is how an agent pays its own way over MCP; connecting without a key still works for evaluation under fair use. See Authentication and billing below for exactly what is billed and what is free.

Presenting your own snk_ key also unlocks style publishes (create_style, set_palette, set_layer_paint) on this endpoint, because you are spending your own credential rather than ours. Without a key, publishes are still refused here: publishing has to be signed with a gateway key, and an open endpoint would otherwise have to sign it with ours. You can also publish by running the server yourself with your own STUDIO_API_KEY (below), or by calling POST /styles on the gateway with your snk_ key. Style reads stay open here, exactly as the gateway serves them. For the same capabilities over plain REST, call the REST API with your snk_ key.

Pick your client:

Claude Code

sh
claude mcp add --transport http mapmap https://mcp.mapmap.ai/mcp

Claude Desktop / claude.ai: Settings, then Connectors, then Add custom connector, and paste:

arduino
https://mcp.mapmap.ai/mcp

Once added it also works from Claude in Slack and on mobile. This is the route for the hosted server; claude_desktop_config.json is for running a local process over stdio, which is further down this page.

Cursor: .cursor/mcp.json in your project (or ~/.cursor/mcp.json globally):

json
{
  "mcpServers": {
    "mapmap": { "url": "https://mcp.mapmap.ai/mcp" }
  }
}

Codex: ~/.codex/config.toml:

toml
[mcp_servers.mapmap]
url = "https://mcp.mapmap.ai/mcp"

VS Code (Copilot agent mode): .vscode/mcp.json:

json
{
  "servers": {
    "mapmap": { "type": "http", "url": "https://mcp.mapmap.ai/mcp" }
  }
}

Windsurf: ~/.codeium/windsurf/mcp_config.json:

json
{
  "mcpServers": {
    "mapmap": { "serverUrl": "https://mcp.mapmap.ai/mcp" }
  }
}

From code: the Claude API's MCP connector takes the URL directly:

json
{
  "mcp_servers": [
    { "type": "url", "url": "https://mcp.mapmap.ai/mcp", "name": "mapmap" }
  ]
}

Any other MCP client that supports streamable HTTP works the same way: give it the URL, no command, no environment variables.

Agent skills (knowledge, not tools)

The MCP server gives an agent live tools; agent skills teach it how to build with MapMap: truck and ADR routing patterns, web SDK integration, migrating from Mapbox, self-host operations. They install into Claude Code, Cursor, Codex and compatible agents:

sh
npx skills add Mapmapai/mapmap-agent-skills            # all skills
npx skills add Mapmapai/mapmap-agent-skills --list     # see what's inside

Each of the eleven skills is listed and explained on the Agent skills page.

Comparing agent surfaces

An MCP server is easy to announce and harder to compare, so here is the measurement rather than the claim. Every figure below was read on 3 September 2026 from the repository or page linked beside it, the same way the optimisation limits are sourced. Restate the date if you quote this; these numbers move.

MapMapNextBillion.ai
Tools52, counted from crates/sn-mcp/src/server.rs by scripts/mcp-tool-inventory.mjs, which fails CI when any published surface disagrees15: autocomplete, autosuggest, directions, distance_matrix, geocode_batch, geocode_forward, geocode_reverse, geocode_structured, isochrone, place_lookup, place_search, postcode_lookup, search_along_route, static_map_image, static_route_map (nextbillion-mcp)
TransportHosted streamable HTTP at https://mcp.mapmap.ai/mcp, plus stdio and self-hosted HTTPstdio. The repository's deployment notes describe the hosted streamable-HTTP mode as a placeholder, "deliberately empty in the MVP"
Calling without a keyAnonymous connections dispatch under fair use, rate limited per IP; premium truck and ADR routing needs a keyNot available. The server requires NBAI_API_KEY and exits if it is unset
DocumentationThis page, plus recipes per assistantNo MCP page on docs.nextbillion.ai; one blog post on the marketing site
Resources and promptsSeven resources, two prompts (below)None declared in the repository

Two honest caveats on reading that table. Tool count is not capability: ours is higher partly because ten of the forty-seven are local geometry helpers that make no network call, and because dangerous-goods, clearance, fuel, charging and EV planning are separate products here rather than absent there. And NextBillion's server exposes place tools we do not match one for one, including static_map_image and postcode_lookup. The differences worth acting on are the keyless lane and the hosted transport, because they decide whether an agent can reach the tools at all without a procurement step.

If you are moving an integration across, the endpoint and parameter mapping is on Migrating from NextBillion.ai, and the commercial side is on the comparison page.

Resources and prompts

Beyond tools, the server publishes seven MCP resources and two prompts. The resources:

URIName
mapmap://guide/llms.txtDeployment orientation (llms.txt)
mapmap://guide/openapi.jsonAPI contract (openapi.json)
mapmap://guide/adr-tunnel-codesADR tunnel-code reference
mapmap://guide/clearanceMeasured clearance reference
mapmap://guide/agent-skillsAgent skills index
mapmap://guide/routing-modeRouting mode (this instance)
ui://mapmap/route-map-v1.htmlRoute map

The first two are the deployment's own llms.txt and openapi.json. mapmap://guide/routing-mode is the only dynamic one: it reports which backend each network-calling tool on this server process is using right now, gateway-preferred or direct-backend fallback, and why. The ADR, clearance and skills entries are static reference documents. The last, ui://mapmap/route-map-v1.html (MIME type text/html;profile=mcp-app), is not a document to read but the route tool's output template: an HTML page that draws the returned geometry on the MapMap basemap with the distance and duration the tool reported, for clients that render MCP app UIs. It renders the tool's own structured content and calls nothing itself.

The two prompts are plan_truck_route (geocode → route with ADR costing → explain what the profile changed) and restyle_map_to_brand (the restrained brand-to-palette method, wired to the style tools). Connecting the server therefore delivers the knowledge layer as well as the tools: a client can read the orientation and the skills index without a single web request.

Two of those resources are static arguments rather than reference tables, and they are worth reading before an agent reports either kind of answer to a person. mapmap://guide/adr-tunnel-codes is what an ADR-costed route does and does not prove. mapmap://guide/clearance is the same job for check_clearance_on_route: why an unchanged truck route is not a clearance, why a surveyed measurement is not a signed height, and why ground the survey did not cover is reported as not surveyed rather than as clear. They share a sentence on purpose, and it is the sentence both products turn on. Full prose: measured clearance on a route.

Run it yourself

Everything below is for running sn-mcp against your own deployment: it is a thin front that needs the routing engine and (optionally) the geocoder, the VROOM optimisation solver and the gateway's style API reachable over HTTP. If you don't have one yet, start with Self-hosting.

Tools

ToolWhat it does
routeTurn-by-turn route in any costing: auto (car), truck (lorry), bicycle, pedestrian (walking) or motor_scooter. The truck profile {height_m, width_m, length_m, gross_weight_t, hazmat, tunnel_code} merges dimensional and ADR costing (ADR is the European agreement on carriage of dangerous goods by road; tunnel codes B–E restrict which tunnels a hazmat load may use; full tunnel-code table in Conventions); pedestrian {use_lit, type, max_hiking_difficulty} and bicycle {bicycle_type, use_roads, use_living_streets, avoid_bad_surfaces, use_hills} tune their own models. Truck, auto, bicycle, pedestrian and motor_scooter routes may set rationale: true for an opt-in avoided[] explanation of which declared constraints or avoidance-side preferences (hills, surfaces, tolls, unlit streets, hiking caps, access profiles) changed the route (route-divergence basis; it does not identify the physical restriction or feature, and never attributes live traffic; see Why this route). Returns {distance_m, duration_s, summary, maneuvers[], geometry_polyline6, applied_adr}; geometry is a polyline with six decimal digits of precision (polyline6). Set landmarks: true (needs the MapMap gateway) and every manoeuvre that passes a recognisable place gains a landmark_instruction ("Turn right just after the Shell garage") beside the engine's own street-name instruction, which is never replaced. Read it aloud in preference: it is how a passenger gives directions. Nothing is named unless it is recognisable from the road, within 40 m of the junction and not tagged as closed, so plenty of routes return none. Through the MapMap gateway every manoeuvre also carries the road it starts on: country, subdivision, ref (route numbers only), road_class, speed_limit_unit (mph or km/h, from the road's country), speed_limit_sign (vienna or mutcd), speed_limit_posted (the limit as the sign shows it, in the road's unit) and driving_side; each is absent when the map does not know it, never guessed. Set scenic: true (auto costing only) and a peer offer arrives beside the route in scenic, never instead of it: the sentence that justifies it (reason, null when nothing measured above the floor, which is an answer), what the detour costs in words (spoken), the alternative's own geometry, and why every candidate that lost lost (rejections[], with the minutes an over_time_budget one would have cost). It is a re-ranking of the engine's own candidates, not a scenic search, and it costs at most two extra metered route computations.
route_observationsWhat a journey PASSES, in sentences ready to read aloud with their position along the route: the named rivers and canals it crosses, the road it runs on and for how far, the settlements it goes through, the protected landscapes it enters and how high the road climbs. None of this is in a route object, which is why the tool exists: a route is a list of movements and a river crossing is not a movement. {geometry_polyline6 (from route), units? (miles default or kilometers), min_gap_m? (default 2000), max_observations? (1 to 200, default 40)} returns each observation with at_m, kind, a say sentence, a basis naming the map layer and tag behind it, and run_m/offset_m/elevation_m where the claim needs a number beside it. The silence budget is the design: min_gap_m is a floor, and when a route yields more than max_observations the gap widens on its own so the survivors stay spread over the journey, so a short list is the budget working rather than thin data. Read coverage before reporting an empty list: tiles_read of 0 means nothing was read, which is not the same claim as quiet countryside. It never names a hill and never reads a junction number, both deliberately. Costs 10 units a call, flat. Gateway-only; a deployment with no basemap archive answers 501.
heritage_narrationThree to five short spoken lines an hour about the ground a route is on, each at the point it belongs. Nothing is generated at request time: every line is a sentence a person wrote from a public-domain plaque inscription and a person reviewed, compiled into the gateway binary. {geometry_polyline6 (from route), duration_s (supply it: the budget is a claim about time), min_gap_s?, min_gap_m?, max_offset_m? (default 60, hard ceiling 80), max_lines?, voice_format? (opus or m4a, which addresses a cached clip and never synthesises)} returns each line with at_m, offset_m (the measured plaque-to-route distance, the entire warrant for the word "here"), say, plaque_ids and a stable id. Coverage is ONE corridor by design (the Chelsea riverside, 18 reviewed lines over 5 km); every other route answers with no lines and a census saying so. Always read the census: no lines with everything in beyond_reach means the feature does not cover the journey, while entries in silenced_by_budget mean it does and the budget is holding them back. No line says anything is visible or which side of the road it is on. Costs 1 unit. Gateway-only.
search_along_routePlaces along a route with the REAL extra travel time of stopping at each, never a straight-line guess. {origin+destination or geometry_polyline6, query, category?, max_detour_minutes?, costing, truck?} prices candidates through the routing engine (detour = origin→place + place→destination − origin→destination); at most 25 candidates priced per call. Returns results sorted by detour with detour_minutes, detour_km, along_route_position (0–1) and off_route_m. Pass category when you mean a kind of place, query alone when you mean a name or brand. category takes the same vocabulary as nearby_places (the map's lowercased OSM tag values: fuel, cafe, charging_station, parking, pharmacy, …), with colloquial phrases normalised for you ("petrol station" and "gas station" → fuel, "EV charger" → charging_station). A query that is nothing but a category phrase is promoted to the same browse, so query: "fuel" now returns fuel stations instead of every place whose name happens to start "Ful"; anything else ("starbucks", "coffee soho") keeps matching on names, unchanged. When a browse ran the response adds matched_categories (e.g. ["fuel"]), so you can see how your words were read rather than infer it from the results; no such field means name matching answered the call. The tool has no source argument and always searches the map's own places; to search your own uploaded dataset, call the REST POST /route/along with source: "places", where category filters your dataset's taxonomy instead. An explicit category on a deployment whose geocoding is the Photon proxy rather than the first-party index returns a clear tool error (501 reverse-filtering-not-enabled, as on GET /geocode/reverse); a promoted query falls back to name matching there instead of failing.
cheapest_fuel_along_routeCheapest fuel along a route, with the REAL extra travel time of stopping at each station, never a straight-line guess. {origin+destination or geometry_polyline6, fuel? (diesel default, petrol_95, petrol_98, premium_diesel, e85, lpg), max_detour_minutes? (default 10), costing, fill_litres?}. Stations come from the live open-data price feeds (statutory UK Fuel Finder, FR prix-carburants, DE Tankerkoenig), are priced through the routing engine with your costing (a truck profile makes detours respect dimensional and ADR restrictions) and are ranked cheapest first. Each result carries price {value, currency, updated_at, stale} (stale = not verifiably fresher than 24 h), detour_minutes/detour_km and saving_per_litre against the cheapest on-route baseline (fill_litres adds saving_total). Needs the MapMap gateway, and answers a clear error when the deployment carries no fuel-price dataset. Display the returned fuel_attribution with the prices.
weather_along_routeForecast weather along a route, each sample read for the hour you actually reach it. {shape (polyline6), depart_at? (RFC 3339, default now), duration_s? (from route; omitted, an 80 km/h average is assumed), sample_interval_m?} (default 25,000, 5,000 to 100,000, at most 40 samples). Each sample carries its ETA, temperature, rain, snowfall, precipitation type and probability, wind and gust speed, visibility, the WMO weather code and severe (thunderstorm and heavy precipitation codes only); summary is one sentence that always ends by saying the forecast is advisory, never a routing guarantee. A forecast on a 0.05 degree grid at the nearest hour, up to 16 days ahead; samples without data say why rather than reading dry. Needs the MapMap gateway with a weather source configured: live on the hosted API; a self-hosted deployment sets SN_OPEN_METEO_URL and answers a clear error until it does. Display the returned attribution (Open-Meteo, CC BY 4.0) linked to attribution_url, with licence_url linked beside it; derived_fields names the sample fields MapMap derives (precipitation_type, severe) rather than Open-Meteo supplies.
nearby_stopsEngland bus and coach stops near a point, nearest first. {lat, lon, radius_m? (1 to 2,000, default 500), limit? (1 to 50, default 10)}. Each stop carries its NaPTAN ATCO code id (pass it to next_departures), name, indicator (e.g. "Stop S"), locality, position, distance_m and bearing_deg from the point, and lines, the line names the Bus Open Data timetables have calling there. Bus and coach only: rail, tram, metro and ferry stops are not included. Display data, never a journey plan: pass on the returned label and coverage. Needs the MapMap gateway, and answers a clear error when the deployment has no stops index. Display the returned attribution (NaPTAN and BODS, Open Government Licence v3.0).
next_departuresTimetabled bus departures at one stop. {stop (ATCO code), at? (RFC 3339, default now; three service days from today), window_minutes? (1 to 180, default 60), limit? (1 to 30, default 8)}. Each departure carries line, destination, operator, scheduled_at and trip, plus live when a vehicle on that line was seen within 3 km heading towards the stop. These are timetable times: basis is always timetable, a live entry is an observation and never an arrival time, and MapMap does not predict arrivals; pass on the returned label and coverage verbatim. Needs the MapMap gateway, and answers a clear error when the deployment has no departures index. Display the returned attribution.
cheapest_charging_along_routeThe best EV charge points along a route, with the REAL extra travel time of stopping at each one, never a straight-line guess. {origin+destination or geometry_polyline6, connectors? (ccs, type2, chademo, type1, tesla, domestic, other), min_kw?, available_only?, max_detour_minutes? (default 10), costing, truck?}. Charge points come from operator-published feeds, are costed through the routing engine with your costing (a truck profile makes detours respect dimensional and ADR restrictions) and are ranked most powerful first. Each result carries max_power_kw, connector_standards, best_connector, evse_count, detour_minutes/detour_km and, where a live feed backs it, available_now. There is no national charge-point registry, so every deployment covers only the operators it has onboarded: always show the returned coverage_note: an empty results means "none from these operators", never "no chargers here". Statuses are current only when availability_live is true. Needs the MapMap gateway, and answers a clear error when the deployment carries no charge-point dataset. Display the returned charging_attribution with the results.
plan_ev_routePlan a whole electric-vehicle journey, charge stops included. {origin, destination, waypoints?, vehicle? (a published profile "small_hatch"/"saloon"/"suv"/"van" and/or inline battery_kwh, mass_kg, drag_area_m2, aux_kw, connectors), start_soc? (0.9), min_arrival_soc? (0.1), reserve_soc? (0.1, the floor the charge must never drop below mid-route), connectors?, min_kw?, ambient_temperature_c?, max_detour_minutes?}. Energy comes from a published road-load physics model over the route's own legs; charge times are integrated over the vehicle's charging curve capped by the charge point, not energy divided by peak power, which is the single biggest error in naive EV planners. Returns the stops with arrive/depart state of charge, charge time and detour, a per-leg soc_trace, and the journey's driving and charging time. When no plan exists (a charger desert, a connector mismatch, a gap wider than the car's range) the answer is feasible: false with a reason and the furthest point the car can actually reach: that is an answer, not an error to retry, and it must never be described as a plan. gradient_data: "absent" means consumption was modelled on the flat because the deployment had no elevation data. Always show the returned coverage_note, and display charging_attribution with the plan. Needs the MapMap gateway.
plan_dayTurn an itinerary into one navigable multi-stop route. {start, stops[] (each a location or a free-text name to geocode, plus optional dwell_minutes), depart_at?, optimise?, return_to_start?, costing}. With optimise: true a VROOM solver reorders stops for the shortest day. Returns stops in visit order with per-leg duration/distance, arrival/departure times, totals and the full route geometry (polyline6); geocoded names carry a resolution/alternatives to catch ambiguity.
plan_errandsOrder a handful of errands against a hard arrival time, and say honestly whether they fit: the "pick up a prescription, get petrol, and be at the school by quarter past three" tool. {origin, destination, destination_name?, arrive_by (RFC 3339), depart_at?, errands[] (1–5, each either a category (a kind of place from list_place_categories, or a colloquial phrase the server normalises) or a place {lat, lon} the driver already knows, plus id? and dwell_minutes?), max_detour_minutes?}. The server chooses the order and the actual shops, exhaustively, over engine-computed travel times; the agent does none of the arithmetic. When the chain does not fit the answer is feasible: false with the errand that costs the most (blocking_errand), how late the whole chain would be (over_by_s), and the stops of the chain that does fit with dropped naming what had to go: an answer, not an error to retry. Every stop carries the place index id it came from, so nothing in a plan is a generated venue. hours is "open_on_the_tag" (the map's tag covers your arrival, which is evidence and not a promise) or "unknown" (most places carry no hours tag at all); a place the tag proves shut is never proposed. Always show usage_note: this is planned before setting off or by a passenger, never at the wheel. Needs the MapMap gateway and its place index.
reachable_areaReachability contours (isochrones) from an origin within one or more travel-time budgets: walkability/cyclability rings. {origin, contours_minutes[] (1–10 values, each ≤120), costing, polygons?} returns a GeoJSON FeatureCollection, one feature per contour.
check_adr_tunnelPure ADR 8.6.4 tunnel-entry decision: {tunnel_code?, hazmat, tunnel_category} returns decision, reason, explanation and the worst-case forbidden category list. No network; answers instantly.
check_clearance_on_routeMeasured overhead clearance along a route, judged against surveyed point cloud geometry. {origin, destination, height_m, width_m?, margin_m?}. Routes with truck costing, so the search avoids the height restrictions the map has tagged, then walks that corridor over the survey's own heights, station by station. Returns pass, fail, indeterminate or no_verdict with the limiting point, its measured headroom_m, the bound on it (safe_headroom_m, sigma_m, sampling_gap_m) and a deep link to that exact view. Optional width_m asks the corridor-width axis too, reported separately and never as a headroom; without a width that axis answers not_assessed, and with one over ground the survey covers too thinly at the corridor edges it runs the same verdict ladder and comes back indeterminate or no_verdict there while the height axis can still answer. Optional margin_m adds your operating margin to the vehicle before the verdict. An indeterminate carries indeterminate_reasons as codes to branch on, with the same reasons written out in English inside explanation: read out the English, never the code. A pass may carry no limiting point at all, which means the survey found nothing above that corridor. The honesty matters here: this measures physical geometry from a dated survey, it is not a signed or posted height, clearance_enforcement.route_certified is always false, and ground the survey did not cover comes back as not_surveyed_m and is never judged, so a pass is possible over complete coverage and nowhere else. Sparse or stale coverage is reported separately again as insufficient_data_m. Needs the MapMap gateway, which holds the surveys, and answers a clear error when the deployment carries no clearance dataset. Read the mapmap://guide/clearance resource for why an unchanged truck route is not a clearance.
match_traceSnap a recorded GPS trace to the road network and say what it actually travelled over. {shape[] (2-2000 recorded points) or encoded_polyline, costing?} → the matched path as geometry_polyline6 with distance_m/duration_s, then by_road_class and by_admin (distance and time, longest first), by_surface (distance) and toll/bridge/tunnel totals. This is how a dashcam or telematics log becomes a report: which country and region the driving happened in, how much was motorway, how much was tolled, how much was unpaved. Costing decides which roads the trace may match onto, so a walk matched as auto snaps to the carriageway. The roll-ups are summed per matched road segment, so they need not add up to distance_m exactly, and a segment the map places in no admin area comes back as a null-coded by_admin entry rather than being filed under a guess. Needs the gateway.
geocodeForward geocoding, asked two ways and they combine: {query, limit?, focus?, lang?, street?, housenumber?, city?, postcode?, country?} returns candidates with name, label, lat/lon, type and address parts. At least one of query or a structured component is required. query is free text, the way a person types into a search box; the components name the parts of an address separately and are requirements, not hints: city: "London" means a result outside London cannot come back at all, where "London" inside query only reorders. Pass the components whenever you already hold the address in parts (a form, a CRM row, a manifest). country takes an ISO 3166-1 alpha-2 code or a country name ("GB", "United Kingdom"); a value naming no country is refused rather than silently matching nothing, and components need the MapMap gateway (the direct Photon fallback answers free text only). Every gateway hit also carries match: components with a matched/inferred/unmatched verdict per component you sent, score_gap (the top result's score minus the runner-up's) and source (mapmap-index or photon). Read it before acting on an address: an unmatched or inferred component on the top hit means the answer does not carry what you asked for, and a small score_gap means the ranking barely chose, so show the alternatives instead of picking one.
reverse_geocodeThe inverse of geocode: coordinates to the nearest places, nearest first. {lat, lon, limit?, heading_deg?, fov_deg?} (default 5, max 10) returns name, one-line label, lat/lon, type, address parts and distance_m, plus categories and a details object of display tags (opening_hours, website, phone, wikipedia, …) on POI hits where the index carries them. Every hit also carries bearing_deg and a spoken direction; see nearby_places below for how heading_deg and fov_deg work.
places_in_viewWhat is over THERE, and whether you could see it. Give it a position AND a bearing ({lat, lon, bearing_deg, fov_deg? default 60, the FULL width of the cone, radius_m? default 1000, max 5000, category?, name?, limit?, eye_height_m? default 1.6, visible_only?}) and it returns the places inside that cone, each with distance_m, its own bearing_deg, a signed angular_offset_deg (negative left, positive right), a spoken direction, and a visibility block. Read the visibility block before saying anything: clear means nothing in the data stands in the way, occluded names what does (a hill, or a building with its height and whether that height was ever mapped), and unknown means no check could run, so the result carries no visibility claim at all. basis says what the verdict rests on: terrain-only in a town means hills were checked and buildings were not, which is weak. out_of_sector counts matching places that were near but not in front, so "nothing in that direction, though there are three behind you" stays sayable. Visibility is computed against the bare-earth elevation model and OpenStreetMap building footprints; it is modelled from maps, not observed, and trees, walls, scaffolding, weather and which way a frontage points are not in it. Building-height coverage in OpenStreetMap is uneven outside large city centres, which is why an obstruction resting on the map's default height says so. Needs the MapMap gateway.
nearby_placesWhat is NEAR a point, nearest first with distance_m: by category (cafe, fuel, charging_station, parking, pharmacy, supermarket, …, matched against the map's lowercased OSM tag values, with colloquial names normalised: coffee → cafe, petrol → fuel), by name for a brand ("the nearest Lloyds"), or both to disambiguate one from the other (Lloyds + bank excludes Lloyds Pharmacy). Use it instead of geocode for proximity questions: geocode ranks a brand's branches worldwide and only biases by proximity, so it will return a branch in another city over the one 100 m away. {lat, lon, category?, name?, radius_m? (default 2500, max 100000), limit?, heading_deg?, fov_deg?}; a category or name the map does not carry returns an empty list, never an error. Every result carries bearing_deg (degrees clockwise from true north) and a direction phrased for speech. Pass heading_deg (which way the user is facing, same convention) and results are described egocentrically ("ahead and slightly to your right, about 80 metres") with a signed relative_bearing_deg, negative left, positive right; without it the phrasing falls back to cardinals ("to the north-east"), so voice clients work with or without a compass. fov_deg sets the full width of a cone around the heading and drops what falls outside it, counting the casualties in out_of_view: a non-zero count means there ARE matching places nearby, just not in front of the user, which is a different answer from "nothing nearby". Needs the MapMap gateway.
list_place_categoriesThe canonical place-category vocabulary the place tools accept: each entry is a category token (fuel, charging_station, hgv_parking, …), the aliases that colloquially name it ("petrol station", "EV charger", "lorry park") and a one-line description. Read it rather than guessing a category: an unknown token matches nothing and returns an empty result rather than an error. Cuisines, brands and names are not categories; search those as free text. Sorted, identical on every call, local: no network, no quota.
verify_placesCheck whether places (and itineraries) an AI mentioned are real, findable and physically possible: the check to run before acting on a model's suggested venues. Pass structured claims[{name, locality?, id?, sequence?, claimed_time?}] (up to 20 per call) or free text (up to 8,000 characters); the two are mutually exclusive. Every claim gets exactly one of three verdicts, never a boolean: verified (matched a real place, with its stable id and the source and date of the evidence), contradicted (a dated, sourced fact rules it out) or unverified (no evidence either way). A missing match is always unverified: the tool never asserts that a named real business does not exist or has closed. Claims with increasing sequence values that both carry claimed_time form itinerary legs checked for feasibility through matrix, catching "breakfast in Bath, 10am meeting in Edinburgh". Pass a locality with every claim. A bare name is searched against the whole world, so a short or generic name can come back verified against a same-named place in another country, which is a real match to a real place but not the one that was claimed. Free text is best-effort extraction (quoted phrases and Title Case runs after "at", "in", "near", "to"), attaches no locality and runs no feasibility pass, so use it to triage prose and re-check anything that matters as a structured claim. Full contract in API reference.
matrixMany-to-many travel matrix: {origins[], destinations[], costing, truck?} returns durations_s[i][j] (seconds) and distances_m[i][j] (metres); null cells are unreachable pairs.
elevationTerrain elevation: {points[]} for a bare list of coordinates, or {encoded_polyline, resample_distance_m?} for an along-route profile, not both. Returns one sample per point/resampled point, elevation_m null wherever the engine's terrain-tile set has no coverage (never a guess); the encoded_polyline form also returns each sample's resampled lat/lon and cumulative range_km.
optimise_routesMulti-vehicle, multi-stop route optimisation (VRP) via a VROOM solver sidecar, over a travel-time matrix computed by our routing engine, so with costing truck every optimised route respects dimensional and ADR restrictions. Returns a summary, unassigned tasks and per-vehicle routes with ordered steps. Also takes territories (named polygons bounding which vehicle may serve which stop), per-vehicle reloads (return to a depot, reload, go out again; the shift is split into fixed windows before the solve, so the plan is conservative but not optimal, and the returned reloads block states that), relax_if_unassigned (re-solve once with wider time windows if work was left over, returning the relaxed plan only if it assigns more, and saying so in relaxation) and emissions/avoid_zones for clean-air zones. Those four need the gateway; without one the tool refuses rather than ignoring them. Size cap: problems with more than 200 unique locations return a tool error asking you to split the problem, as does one spread wider than the engine's matrix span (1,500 km on the hosted gateway, 400 km on a stock self-host): use cluster then optimise per cluster, or submit_optimise_job for up to 2,000. A problem with tens of thousands of tasks at long travel times can also be refused as too large for the solver's cost arithmetic (optimisation-beyond-solver on the gateway, the same message without one); split it the same way. A stop that cannot be reached is never planned as a 0 m leg.
order_stopsPut a single run's stops in the best visiting order ("order my errands"): a thin wrapper over the same optimiser for the one-vehicle case. Takes start {lat, lon} and stops (1–100 entries of {location, label?, service_s?}); optionally an end destination or round_trip: true to return to the start. Costing auto = car, truck = lorry (pass truck as in route for a restriction-aware stop order). Returns the stops in visit order with arrival offsets in seconds, plus total duration and distance.
clusterGroup stops into balanced geographic clusters so a day too large for one optimisation can be optimised a cluster at a time. {locations[] ({id, lat, lon, load?}), clusters or max_cluster_locations or max_cluster_load, territories?, seed?} → clusters with their member ids, count, summed load, centroid and territory, plus a balance block saying whether the ceiling had to be relaxed. Straight-line only: distances are between coordinates, not along roads, so use it to decide which stops belong together and optimise_routes to decide the order. The answer says so in a basis sentence; show it. Deterministic for a given seed. Gateway-only.
replan_routesRe-plan a fleet part-way through its shift. Send the day back: the original optimise_routes problem in full, plus progress (completed stop ids in the order served, an optional current position, unavailable for a vehicle that has dropped out) and/or changes (cancel, add jobs, add shipments). Nothing is stored between calls: MapMap holds no plan, no vehicle position and no completion log, so a re-plan is reproducible and there is no server-side object to go stale. Completed stops are locked by construction (removed from the problem, vehicles started from where they are), so the solver cannot move them. Returns the optimise_routes plan for the remaining work plus a replan block naming what was locked, released or forced unassigned. Multi-trip vehicles cannot be re-planned: a completion does not say which trip it belongs to, so a problem whose vehicles declare reloads is refused with what to send instead. Billed on the remaining problem. Gateway-only.
submit_optimise_jobSubmit a problem too large for one request to the asynchronous lane. {kind: "optimise" / "replan" / "matrix", problem, webhook_url?} where problem is exactly the matching synchronous tool's input → a 202 with a job id. Ceilings are 2,000 unique locations (against the synchronous 200) and 40,000 matrix elements (against 10,000). Units are charged on submission and refunded in full if the job fails. Poll get_job; the optional signed webhook_url is for infrastructure that cannot poll. A field the matching tool's input does not have is refused by name rather than dropped: the HTTP API accepts some the MCP tools have not surfaced yet, and a job queued without a constraint you asked for is worse than one that was never queued. Gateway-only.
get_jobRead an asynchronous job: status (queued, running, succeeded, failed), a terminal flag saying whether it will ever leave that status, and the result inline once it exists, exactly the body the synchronous tool would have returned. Polling is free: the gateway meters the submission, not the reads, so check every few seconds rather than rationing. units_charged is what the submission drew and refunded says whether a failure handed it back. Another key's job is not found, never forbidden. Gateway-only.
validate_geodataCheck whether a dataset's declared coordinate reference system actually describes its own coordinates, before you draw it on a map. Pass declared_crs (e.g. "EPSG:4326") and a sample of the raw coordinates as {x, y} in the dataset's own units, deliberately not named lon/lat, because whether they are degrees is exactly the question. Returns a verdict of consistent, suspect or impossible, the problems in plain language, and suggestions saying where the numbers actually point when read another way. It catches the failures that are otherwise silent: swapped lat/lon axes, degrees labelled as metres, and Web Mercator or another projection mislabelled with a UTM or national-grid code, the kind of header that lands a survey in the wrong hemisphere. A sanity check, not a reprojection: it never transforms coordinates and carries no projection database, so it can be silent about an exotic CRS but is never wrong about a UTM easting of 6.1 million. Local check, no network.
report_map_issueQueue a first-party map correction for human or agent review, never an automatic OSM edit. {location {lat, lon}, category, description?, way_id?, evidence_url?}; category is one of road_closed, wrong_restriction, wrong_speed_limit, access_changed (and related). Appends the observation as NDJSON to the deployment's review queue (SN_MAP_ISSUES_DIR) and returns an acknowledgement. It stops there: the queue is local to the MCP server, it is not sent to the gateway, and it does not appear on the public Map Health board. To get a report onto that board, and an agent diagnosis with it, call the gateway's POST /map-issues instead (different category vocabulary: road-missing, restriction-wrong and so on).
camera_incidentsRoad incidents a traffic camera saw and a vision model confirmed, in force now, near a point, in a box or along a route shape: what each asserts (closed or a speed) on which ways, the clip URL the verdict was made on, the verifier and its confidence, and when it lifts (always within minutes). Ranks below official closures; an empty list is no camera-verified incident, never a clear road. Off unless the deployment arms camera-verified incidents (501 otherwise). Powered by TfL Open Data.
set_temporary_closureOPERATOR WRITE, ADMIN ONLY, off unless the deployment arms camera-verified incidents. Asserts a short-lived closure or speed on named OSM ways for ttl_secs (60 to 86400) with a reason and an optional evidence URL; it expires on its own and ranks below official closures. Never called without a person's explicit approval of that exact closure; refuses on the hosted endpoint and anywhere SN_ADMIN_TOKEN is not set. applied_by_live_writer says whether any road actually changes.
submit_integration_retroSend MapMap a structured end-of-integration retro so the friction you hit gets fixed. {what_built (required, ≤500 bytes), problems[{area, description ≤1000 bytes, workaround_found}], gotchas[], wins[], docs_gaps[] (≤20 entries each, ≤500 bytes per entry), agent_name?, sdk_version?}; area is one of sdk, api, mcp, docs, billing, self-host, other. Only call it if the developer has approved sending feedback to MapMap, and at most once, after the integration works or you stop trying. Only the structured fields above are ever sent: never your conversation, prompts or code. With GATEWAY_URL + GATEWAY_API_KEY set it POSTs to the gateway's /v1/feedback, and falls back to the local queue at SN_RETROS_DIR whenever that delivery cannot happen (no key set, gateway unreachable, or any non-2xx answer) so a retro is never lost. Terms, retention and opt-out: Agent Feedback Programme.
get_usageWhat your own key has spent, so an agent can check itself mid-task. Reading it costs no quota. Optional from/to (YYYY-MM-DD UTC, inclusive, at most 92 days apart; default the current month to date) → days (per-day, per-endpoint), totals per endpoint, total_units, plus month_used_units against monthly_quota_units and the prepaid balance_millipence. Everything is counted in units (weighted quota units, where a heavier endpoint costs more than one unit per request), so never report these as a number of calls. identity_pooled: true means the quota is shared with the owner's other keys, and month_used_units is the live figure the quota check enforces on, independent of the range, so over a whole month it can read slightly higher than total_units. It is the MCP face of GET /v1/usage. The key that authenticates the call is the key reported on. Needs the gateway.
list_style_layersList everything a map style theme can restyle: the first-party named bases a style can start from (id, label, what each is for, and whether it expects a website-side overlay), the palette slots (with light/dark defaults), the skeleton layer ids and the source-layers. Local lookup, no network, no configuration needed.
get_styleFetch a hosted style's latest theme document and compiled style URL. Public read, unmetered.
create_styleCreate a hosted style from a named base and/or a theme document (metered publish). base takes any id from list_style_layers (light, dark, streets, midnight, navigator-day, navigator-night, fleet, outdoor, dataviz, backdrop, print); it is laid down first and the theme document is merged over it, so "Streets with darker water" is one call.
set_paletteRecolour a style by setting palette slots, published as a new immutable version (metered publish).
set_layer_paintOverride one paint property on one style layer, e.g. road width or opacity, published as a new immutable version (metered publish).
check_style_contrastAudit a map style's colour contrast against WCAG 2.1 (4.5:1 for label text, 3:1 for graphics like the route line), across both the light and dark palette variants. Pass a hosted style_id or an inline theme document (as accepted by create_style). Advisory: failing pairs list the palette slots to adjust with set_palette, and publishing is never blocked on contrast. Local check, no network.

Local geometry (no network, no quota)

Every tool above reaches a backend. These do not: they are pure computation over the coordinates you supply, so they work offline, cost nothing to serve, are never rate limited or metered, and keep answering when a backend is unavailable. They exist so an agent reasoning about space can stay in one tool surface instead of leaving it to do arithmetic.

All measurement is geodesic on the WGS84 ellipsoid, not a spherical approximation, so results agree with the datum the rest of the platform uses.

ToolWhat it does
geo_distanceDistance in metres between two coordinates. Straight-line (as the crow flies), not a driving distance: use route or matrix for travel distance and time.
geo_bearingInitial bearing from one coordinate to another, degrees clockwise from true north (0–360). It is the bearing at the start of the geodesic; over long distances the bearing changes en route.
geo_destinationThe coordinate reached by travelling a given distance on a given bearing. The inverse of geo_distance + geo_bearing.
geo_point_in_polygonWhether a coordinate lies inside a polygon: delivery zones, catchments, clean-air or congestion zones, site boundaries. The ring is closed automatically if the last point does not repeat the first. Points exactly on the boundary count as outside.
geo_bboxThe axis-aligned bounding box enclosing a set of coordinates, as {min_lat, min_lon, max_lat, max_lon}. Useful for fitting a map view to a set of stops.
geo_centroidThe centroid of a set of coordinates, e.g. to pick a depot location or centre a map.
geo_lengthTotal length in metres of a polyline. Measures the line you supply, not a driven route.
geo_areaArea in square metres enclosed by a ring of 3+ coordinates. Always positive: the answer does not depend on winding direction. Intended for zones and boundaries, not polygons covering more than half the globe.
geo_simplifyDouglas-Peucker simplification of a polyline. tolerance_deg is in degrees, not metres: about 0.0001 drops detail finer than roughly 10 m at the equator. Endpoints are always kept.
geo_nearest_point_on_lineThe closest position on a polyline to a coordinate, plus the geodesic distance to it. The answer may lie between vertices, not only on them: "how far is this address from the route?"

Upstream failures and invalid inputs come back as MCP tool errors with actionable messages, so agents can self-correct. In the tool inputs, a coordinate is a named {lat, lon} object (preferred, because it cannot be read the wrong way round) or a GeoJSON [lon, lat] array, longitude first, the same order every polygon field on the server takes. Answers always write coordinates back as {lat, lon} objects. Distances are metres and durations seconds throughout; see Conventions.

Environment variables

Each upstream is configured by one variable. All are optional at startup; a tool whose upstream is missing answers with an error naming the variable to set.

VariablePowersExample
VALHALLA_URLroute, matrix, elevation, optimise_routes, search_along_route, plan_day, reachable_area, and the itinerary feasibility pass in verify_places (without it the existence checks still run, the legs are simply not checked)http://localhost:8002
VROOM_URLoptimise_routes, and plan_day with optimise: truehttp://localhost:3000
PHOTON_URLgeocode, verify_places, search_along_route, and plan_day name lookupshttp://localhost:2322
STUDIO_URLget_style, create_style, set_palette, set_layer_paint (the gateway hosting the style API)http://localhost:8080
STUDIO_API_KEYstyle publishes only: create_style, set_palette, set_layer_paint (a gateway API key, snk_…); style reads work without itsnk_...
GATEWAY_URLsearch_along_route (it searches the key's own /places dataset), match_trace (map matching plus the route report), get_usage (the metering ledger), cluster, replan_routes, submit_optimise_job and get_job (the fleet endpoints and the asynchronous job queue), route with landmarks or emissions, optimise_routes with territories, reloads, relaxation or zones, and submit_integration_retro delivery; falls back to STUDIO_URL; both name the same gatewayhttp://localhost:8080
GATEWAY_API_KEYauthenticates those same two; falls back to STUDIO_API_KEYsnk_...
SN_MAP_ISSUES_DIRreport_map_issue: the NDJSON review queue it appends to/feedback/map-issues
SN_RETROS_DIRsubmit_integration_retro safety net: every retro the gateway cannot take (no key, unreachable, rate-limited) lands here instead of being lost. Set it even when the gateway path is configured/feedback/retros
SN_MCP_USAGE_DIRoptional per-tool-call log: one NDJSON line of {ts, tool, ok, duration_ms, error_code?} per call; never arguments or payloads. Unset = tracing only/feedback/mcp-usage

check_adr_tunnel and list_style_layers are fully local and need nothing.

Install

Status, honestly: the sn-mcp binary is not on a package registry yet. You build it from the source distribution (source access on request) or run it from the self-host distro's Docker image.

From source (Rust toolchain required):

sh
cargo build --release -p sn-mcp
# binary at target/release/sn-mcp; copy it to /usr/local/bin/sn-mcp

As a container (from the distro):

sh
docker build -f distro/Dockerfile --target mcp -t mapmap/mcp .

# stdio, for a desktop MCP client; -i keeps stdin open for the protocol
docker run -i --rm \
  -e VALHALLA_URL=http://valhalla:8002 \
  -e PHOTON_URL=http://photon:2322 \
  mapmap/mcp --transport stdio

(Point the container at your services' network; with the self-host distro, attach it to the compose network.)

Connect over stdio (local process)

Local process (stdio) only. This is for a sn-mcp binary running on the same machine, typically against your own self-host stack. To reach the hosted server at https://mcp.mapmap.ai/mcp from Claude Desktop, use Settings, Connectors, Add custom connector, as above.

Claude Desktop, add to claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json):

json
{
  "mcpServers": {
    "mapmap": {
      "command": "/usr/local/bin/sn-mcp",
      "args": ["--transport", "stdio"],
      "env": {
        "VALHALLA_URL": "http://localhost:8002",
        "VROOM_URL": "http://localhost:3000",
        "PHOTON_URL": "http://localhost:2322",
        "STUDIO_URL": "http://localhost:8080",
        "STUDIO_API_KEY": "snk_..."
      }
    }
  }
}

Claude Code:

sh
claude mcp add mapmap \
  --env VALHALLA_URL=http://localhost:8002 \
  --env VROOM_URL=http://localhost:3000 \
  --env PHOTON_URL=http://localhost:2322 \
  --env STUDIO_URL=http://localhost:8080 \
  --env STUDIO_API_KEY=snk_... \
  -- /usr/local/bin/sn-mcp --transport stdio

(The server key, mapmap here, is your choice.) Set only the variables for the tools you want; the server starts either way and logs a stderr warning for each missing upstream.

Connect over HTTP (remote agents)

The self-host distro runs the server with the mcp compose profile (default port 8200, endpoint path /mcp):

sh
cd distro
docker compose --profile mcp up -d

Out of the box the compose service sets only VALHALLA_URL and PHOTON_URL. To enable optimise_routes, uncomment the vroom sidecar service and the service's VROOM_URL line; to enable the style tools, uncomment STUDIO_URL and STUDIO_API_KEY (comments in the compose file mark all three).

Then point your client at it:

json
{
  "mcpServers": {
    "mapmap": {
      "type": "http",
      "url": "http://your-host:8200/mcp"
    }
  }
}

Or with Claude Code:

sh
claude mcp add --transport http mapmap http://your-host:8200/mcp

Example: a dimensioned truck route

A route call with a hazmat lorry profile:

json
{
  "origin": { "lat": 51.5074, "lon": -0.1278 },
  "destination": { "lat": 53.4808, "lon": -2.2426 },
  "costing": "truck",
  "truck": {
    "height_m": 4.0,
    "gross_weight_t": 40,
    "hazmat": true,
    "tunnel_code": "C"
  }
}

Structured result (truncated):

json
{
  "distance_m": 335412.0,
  "duration_s": 15102.0,
  "summary": "M1, M6",
  "maneuvers": [
    {
      "instruction": "Drive northwest on Whitehall.",
      "distance_m": 254.0,
      "duration_s": 41.0
    }
  ],
  "geometry_polyline6": "}vd~zAxwl@...",
  "applied_adr": {
    "hazmat": true,
    "tunnel_code": "C",
    "forbidden_tunnel_categories": ["C", "D", "E"],
    "costing_options": { "truck": { "height": 4.0, "...": "..." } }
  }
}

applied_adr shows exactly which ADR costing was merged into the request, so an agent can explain why a route avoids a tunnel.

Authentication and billing

Two different keys do two different jobs, and it helps to keep them apart:

  • SN_MCP_AUTH_TOKEN is a self-hosted server's door key. Set it and every HTTP request must carry Authorization: Bearer <token> or get a 401. It gates access to the server; it is not a billing credential and never reaches the gateway.
  • A gateway key (snk_…) is a caller's billing credential. Present it and every metered call you make is metered and billed to that key.

Bill metered calls to your own key

On the streamable-HTTP transport, present your gateway key as an ordinary bearer header:

makefile
Authorization: Bearer snk_your_key

The server reads that header per request and routes every metered call in the tool invocation through the MapMap gateway on your key, so the usage meters and bills to you rather than to the operator's shared key. This is what lets an agent pay per call on the hosted endpoint. It also works on a self-hosted HTTP server that is pointed at a gateway.

Only a bearer token with the snk_ prefix is treated as a billing key. An operator's SN_MCP_AUTH_TOKEN, or any other token, is not mistaken for one: the call then runs as an anonymous caller on the operator's key instead. So if you expect to be billed, check that the token you send actually begins snk_.

Set it in your client the same way you set the URL. Claude Code:

sh
claude mcp add --transport http mapmap https://mcp.mapmap.ai/mcp \
  --header "Authorization: Bearer snk_your_key"

Any streamable-HTTP client that lets you set request headers works the same way:

json
{
  "mcpServers": {
    "mapmap": {
      "type": "http",
      "url": "https://mcp.mapmap.ai/mcp",
      "headers": { "Authorization": "Bearer snk_your_key" }
    }
  }
}

What is billed, and what is free

  • Metered to your key are the tools that reach the MapMap gateway: route, geocode, reverse_geocode, matrix, reachable_area, optimise_routes, order_stops, cluster, replan_routes, submit_optimise_job, search_along_route, cheapest_fuel_along_route, weather_along_route, nearby_stops, next_departures, cheapest_charging_along_route, plan_ev_route, plan_errands, nearby_places, plan_day, elevation, and the itinerary feasibility pass in verify_places, plus the style publishes below. get_job is not metered: reading a job you submitted is free, deliberately, so polling is never something to ration. Each draws down that key's allowance and is priced exactly as the same capability over the REST API; see Pricing, and x402 payments for what happens once a key is past its allowance (the 402-versus-429 distinction is the same as on the gateway).
  • Style publishes (create_style, set_palette, set_layer_paint) are metered publishes. A caller presenting their own snk_ key may publish with it on any transport, including an untrusted HTTP one, because they are spending their own credential rather than the operator's.
  • Never metered, no key needed: the local-geometry tools (geo_*), validate_geodata, check_adr_tunnel, list_style_layers, check_style_contrast, and style reads (get_style). They are pure local computation or public reads, so they answer on any transport, with or without a key.

One nuance for self-hosting: the routing, matrix, geocoding and optimisation tools are metered only when the deployment reaches them through the gateway (as the hosted endpoint does, and as a self-host does once GATEWAY_URL and a key are set). A self-host wired directly to the engines (VALHALLA_URL, VROOM_URL, PHOTON_URL) with no gateway key calls them straight, and those calls are not API-key metered.

With no caller key

  • Anonymous over HTTP dispatches unchanged on the operator's server. On the hosted endpoint that is the fair-use evaluation lane (rate limited per client IP, 429 with Retry-After over the limit), and style publishes are refused, because the server will not spend its own operator credential on an anonymous caller's behalf: hosted style ids are enumerable through the gateway's public GET /styles.
  • Over stdio there are no per-request headers, so there is no caller key to read. You own the process, and calls use whatever key it was started with (GATEWAY_API_KEY, STUDIO_API_KEY), exactly as before.

Self-hosted HTTP server hardening

  • The HTTP /mcp endpoint is unauthenticated unless you set SN_MCP_AUTH_TOKEN, in which case requests must carry Authorization: Bearer <token> or get a 401 (a caller may also carry their own snk_ billing key; the two do not conflict). It binds 127.0.0.1:8200 by default; pass --listen to widen that deliberately (the container image does, since a published port cannot reach a loopback bind). Anyone who can reach the port can call every keyless tool, so still run it inside your trust boundary, restrict it by network policy or add auth at your reverse proxy, and terminate TLS there exactly as for the gateway.
  • In production pass --allowed-host (repeatable) so Host-header validation defends against DNS rebinding; when omitted, validation is off and the server warns at startup.

Troubleshooting

The server starts even with no upstreams configured, and tells you on stderr what won't work (stdout stays clean for the MCP protocol):

  • VALHALLA_URL is not set: route, matrix, elevation, optimise_routes, search_along_route, plan_day and reachable_area will answer with errors
  • VROOM_URL is not set: optimise_routes (and plan_day optimisation) will answer with errors
  • PHOTON_URL is not set: geocode, verify_places and search_along_route will answer with errors
  • STUDIO_URL is not set: the hosted style tools (get_style, create_style, set_palette, set_layer_paint) will answer with errors
  • STUDIO_API_KEY is not set: style publishes (create_style, set_palette, set_layer_paint) will answer with errors; style reads still work
  • SN_MAP_ISSUES_DIR is not set: report_map_issue will answer with errors
  • neither GATEWAY_URL+GATEWAY_API_KEY nor SN_RETROS_DIR is set: submit_integration_retro will answer with errors
  • SN_RETROS_DIR is not set: a retro the gateway cannot take (unreachable, rate-limited, key rejected) has nowhere to fall back to and is lost

The same guidance surfaces at call time: an unconfigured tool returns an MCP tool error naming the exact variable to set.

Next steps