# 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
(thirty-four 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](/docs/self-host). Both are covered below.

## Connect your agent (hosted, no install)

The hosted MCP server speaks streamable HTTP at:

```
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](#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](/docs/api-reference) with
your `snk_` key.

Pick your client:

**Claude Code**

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

**Claude Desktop**: `claude_desktop_config.json`
(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):

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

**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 ten skills is listed and explained on the
[Agent skills](/docs/skills) page.

## Resources and prompts

Beyond tools, the server publishes MCP **resources** (the deployment's own
`llms.txt` and `openapi.json`, the ADR tunnel-code reference, the
measured-clearance reference and the agent-skills index) and two **prompts**:
`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](/docs/clearance).

**Status, honestly:** resources and prompts ship with the next server
release; they are in the source now and appear on the hosted endpoint at
its next deploy. Tools are unaffected either way.

## 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](/docs/self-host).

## Tools

| Tool | What it does |
|---|---|
| `route` | Turn-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](/docs/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](/docs/api-reference#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. |
| `search_along_route` | Places 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, source?}` 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. With `source: "places"`, `category` filters your own dataset's taxonomy exactly as it always has, and the two vocabularies never cross-filter. 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`](/docs/api-reference#endpoints)); a promoted query falls back to name matching there instead of failing. |
| `cheapest_fuel_along_route` | Cheapest 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. |
| `plan_day` | Turn 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. |
| `reachable_area` | Reachability 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_tunnel` | Pure 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_route` | Measured 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. |
| `geocode` | Forward geocoding: `{query, limit?, focus?}` returns candidates with name, label, lat/lon, type and address parts. |
| `reverse_geocode` | The 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. |
| `nearby_places` | What 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. |
| `verify_places` | Check 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](/docs/api-reference#verify-places-the-geo-hallucination-firewall). |
| `matrix` | Many-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. |
| `elevation` | Terrain 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_routes` | Multi-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. 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 400 km matrix span. |
| `order_stops` | Put 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. |
| `validate_geodata` | Check 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_issue` | Queue 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](/map-health) board. To get a report onto that board, and an agent diagnosis with it, call the gateway's [`POST /map-issues`](/docs/api-reference#endpoints) instead (different category vocabulary: `road-missing`, `restriction-wrong` and so on). |
| `submit_integration_retro` | Send 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`](/docs/api-reference#integration-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](/legal/agent-feedback). |
| `list_style_layers` | List everything a map style theme can restyle: palette slots (with light/dark defaults), skeleton layer ids and source-layers. Local lookup, no network, no configuration needed. |
| `get_style` | Fetch a hosted style's latest theme document and compiled style URL. Public read, unmetered. |
| `create_style` | Create a hosted style from a theme document (metered publish). |
| `set_palette` | Recolour a style by setting palette slots, published as a new immutable version (metered publish). |
| `set_layer_paint` | Override one paint property on one style layer, e.g. road width or opacity, published as a new immutable version (metered publish). |
| `check_style_contrast` | Audit 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.

| Tool | What it does |
|---|---|
| `geo_distance` | Distance 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_bearing` | Initial 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_destination` | The coordinate reached by travelling a given distance on a given bearing. The inverse of `geo_distance` + `geo_bearing`. |
| `geo_point_in_polygon` | Whether 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_bbox` | The 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_centroid` | The centroid of a set of coordinates, e.g. to pick a depot location or centre a map. |
| `geo_length` | Total length in metres of a polyline. Measures the line you supply, **not** a driven route. |
| `geo_area` | Area 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_simplify` | Douglas-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_line` | The 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,
coordinates are named `{lat, lon}` objects (unambiguous, unlike positional
arrays); distances are metres and durations seconds throughout; see
[Conventions](/docs/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.

| Variable | Powers | Example |
|---|---|---|
| `VALHALLA_URL` | `route`, `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_URL` | `optimise_routes`, and `plan_day` with `optimise: true` | `http://localhost:3000` |
| `PHOTON_URL` | `geocode`, `verify_places`, `search_along_route`, and `plan_day` name lookups | `http://localhost:2322` |
| `STUDIO_URL` | `get_style`, `create_style`, `set_palette`, `set_layer_paint` (the gateway hosting the style API) | `http://localhost:8080` |
| `STUDIO_API_KEY` | style **publishes** only: `create_style`, `set_palette`, `set_layer_paint` (a gateway API key, `snk_…`); style reads work without it | `snk_...` |
| `GATEWAY_URL` | `search_along_route` (it searches the key's own `/places` dataset) and `submit_integration_retro` delivery; falls back to `STUDIO_URL`; both name the same gateway | `http://localhost:8080` |
| `GATEWAY_API_KEY` | authenticates those same two; falls back to `STUDIO_API_KEY` | `snk_...` |
| `SN_MAP_ISSUES_DIR` | `report_map_issue`: the NDJSON review queue it appends to | `/feedback/map-issues` |
| `SN_RETROS_DIR` | `submit_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_DIR` | optional 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)

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:

```
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`, `search_along_route`, `cheapest_fuel_along_route`,
  `nearby_places`, `plan_day`, `elevation`, and the itinerary feasibility pass
  in `verify_places`, plus the style publishes below. Each draws down that
  key's allowance and is priced exactly as the same capability over the
  [REST API](/docs/api-reference); see [Pricing](/pricing), and
  [x402 payments](/docs/x402) 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

- [Self-hosting](/docs/self-host): run the deployment these tools talk to
- [Route optimisation](/docs/optimisation): the VRP problem model behind `optimise_routes`
- [Conventions](/docs/conventions): units, coordinate order, ADR tunnel codes, error envelope
- [API reference](/docs/api-reference): the same capabilities over REST
