Skip to main content
Glama
BoykoNeov

hike-finder

by BoykoNeov
README.md
# hike-finder-mcp

[![CI](https://github.com/BoykoNeov/hike-finder-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/BoykoNeov/hike-finder-mcp/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Find **marked hiking routes from OpenStreetMap** and filter them by **real,
locally-computed elevation gain and distance** — not numbers scraped from
trail-description websites — plus **shape and access**: whether a route is a loop,
and whether you can reach it by **car**, **chairlift** or **public transport**.

It runs three ways on one engine: a **command-line tool**, a **local web UI** (a
map you pan to your area), or an **MCP server** for LLM clients. The CLI and web
UI need **no LLM and no MCP client** — they're plain standalone programs.

---

## Getting started (new machine, from zero)

**First time on this machine? Do these six steps in order.** They take about ten
minutes. You do **not** need an account, an API key, a credit card, or a server —
everything runs on your own computer against public, free data.

Copy each block into a terminal. On Windows use **PowerShell** (press `Win`, type
`powershell`, hit Enter); on macOS or Linux use **Terminal**.

### 1. Install Python and git

You need [**Python 3.10 or newer**](https://www.python.org/downloads/) and
[**git**](https://git-scm.com/downloads). Install both, then close and reopen your
terminal so it picks them up, and check:

```bash
python --version      # want 3.10 or higher — on Windows try `py --version` if this fails
git --version
```

> **Windows tip:** in the Python installer, tick **"Add python.exe to PATH"** on
> the first screen. If you missed it, use `py` instead of `python` everywhere below.

### 2. Get the code

```bash
git clone https://github.com/BoykoNeov/hike-finder-mcp.git
cd hike-finder-mcp
```

(No git? Use the green **Code → Download ZIP** button on the GitHub page, unzip it,
and `cd` into the unzipped folder instead.)

### 3. Make a private Python environment

This keeps the tool's dependencies out of your system Python. Run it *inside* the
`hike-finder-mcp` folder.

**Windows (PowerShell):**

```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
```

**macOS / Linux:**

```bash
python -m venv .venv
source .venv/bin/activate
```

Your prompt now starts with `(.venv)`. You must do this activation step again in
every new terminal window.

> **Windows: "running scripts is disabled on this system"?** Either allow it once
> with `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass` and re-run the
> activate line, or skip activation entirely and prefix every later command with
> `.venv\Scripts\` — e.g. `.venv\Scripts\python -m pip install -e .` and
> `.venv\Scripts\hike-finder --help`.

### 4. Install the tool

```bash
pip install -e .
```

That gives you the `hike-finder` command line tool and the `hike-finder-web` map
UI, with one dependency (`requests`). The MCP server, the high-accuracy offline
elevation backend, and the test suite are optional add-ons — see
[Install](#install).

### 5. Tell OpenStreetMap who you are (one line — skip it and nothing works)

The free OpenStreetMap servers this tool reads from require every program to
identify itself with a real contact address. If you don't, **every search fails
with `406 Not Acceptable`.** This is the single most common first-run problem.

Set it once per terminal session, using **your own** email:

**Windows (PowerShell):**

```powershell
$env:HIKE_OVERPASS_UA = "you@example.com"
```

**macOS / Linux:**

```bash
export HIKE_OVERPASS_UA="you@example.com"
```

To make it permanent, add that line to your PowerShell profile
(`notepad $PROFILE`) or your `~/.bashrc` / `~/.zshrc`. You can also pass it per
command with `--user-agent you@example.com`, or type it into the Contact box in
the web UI.

**Despite the name, this one address identifies you to every service the tool
touches** — the trail data (Overpass), place-name lookup (Nominatim) and the
elevation API all send it. There is only ever one to set.

> **There is no signup, and no email registration that raises your limits.** The
> address is not sent to any account system — it just travels with each request so
> the volunteer server operators can email you instead of silently blocking you if
> something goes wrong. For what the real limits are and how to get more headroom,
> see [Contact, quotas and rate limits](#contact-quotas-and-rate-limits).

### 6. Check it works

Offline first — this touches no network at all:

```bash
hike-finder --help
```

If that prints a usage screen, your install is good. Now a real search, by
**place name** so you don't have to find any coordinates:

```bash
hike-finder --place "Spindleruv Mlyn" --max-distance 10
```

The first line tells you which place the name resolved to, then each hike prints
on one line — name, length, climb and descent, and what it found nearby:

```text
Area: Špindlerův Mlýn, okres Trutnov, Královéhradecký kraj, Česko (50.7256, 15.6068) — searching 11.6 x 12.3 km
[Ž] Nad Dolním Dvorem - Klínovka — 8.26 km, +754 m / -44 m [one-way] (start 50.6450,15.6552, OSM relation 3199247)
[Ž] U Třídomí - Česká Budka — 9.52 km, +721 m / -65 m [one-way, surface:mixed (88% known)] (start 50.7177,15.5438, OSM relation 64382)
0402 — 9.87 km, +704 m / -320 m [one-way, car, lift:chair_lift, transit:bus stop] (start 50.7315,15.5940, OSM relation 6133813)
```

Steepest first. `[loop]` vs `[one-way]` is the shape; `car`, `lift:` and
`transit:` mean parking, a chairlift or a bus/train stop is mapped near an end.
The first run is the slowest — results are cached on disk, so searching the same
area again is much faster.

### Now pick how you want to use it

- **[Web UI](#option-a--web-ui-easiest-no-coordinates-to-type)** — easiest. Run
  `hike-finder-web`, open <http://127.0.0.1:8765>, pan the map to where you want to
  walk, click search. No coordinates to type.
- **[Command line](#option-b--command-line)** — `hike-finder --place "…"` plus
  filters. Everything the tool can do, scriptable, with `--json` output.
- **[MCP server](#option-c--mcp-server-drive-it-from-an-llm-client)** — optional;
  lets an LLM client (Claude Desktop, Claude Code) run searches for you in plain
  language.

> **Want the slow, fully-explained version** — every step with sample output and
> how to read it? See **[`GUIDE.md`](GUIDE.md)**. This README is the terse
> reference: the full flag list, every environment variable, the filter table.

---

## Why this exists

It targets OSM route *relations* (`route=hiking`/`foot`), the same signed,
maintained trail data — including the Czech **KČT** network — that **mapy.cz**
renders. Distance and elevation gain are computed in this codebase, so the
numbers are consistent and tunable instead of inherited from a third party.

Trail sites (AllTrails, Komoot, mapy.cz) all report *different* gain for the
same trail because elevation gain depends entirely on how you sample and
de-noise the terrain. This tool makes that step explicit and consistent: it
resamples each track to even spacing, smooths the elevation series, and counts
climbs with a hysteresis threshold so DEM noise isn't mistaken for ascent.

## Filters

`find_hikes(south, west, north, east, …)` takes these optional filters:

| Filter | Meaning | Confidence |
|--------|---------|------------|
| `min_gain_m` / `max_gain_m` | elevation gain bounds (m), computed locally | high |
| `min_distance_km` / `max_distance_km` | route length bounds | high |
| `circular` | `true` = loops only, `false` = point-to-point only | high |
| `car_access` | `true`/`false`: is `amenity=parking` mapped near a trail end? | best-effort |
| `chairlift_access` | `true`/`false`: is a ride-up aerialway (chairlift/gondola/cable car) mapped near a trail end? | best-effort |
| `transit_access` | `true`/`false`: is a train station/halt (within 1 km) or tram/bus stop (within 400 m) mapped near a trail end? | best-effort |
| `poi` | only routes that pass a church / ruin / peak / … (see below) | best-effort |

The four boolean filters are **tri-state**: omit = don't care, `true` = require,
`false` = exclude. **Honesty note:** `car_access`/`chairlift_access`/`transit_access`
reflect OSM *mapping*, not the world — a `false` means nothing of that kind is mapped
near the route's ends, not that it's impossible to get there. Loop detection is reliable.

Every result also reports **what you walk on**, read from the
member ways' `surface` and `tracktype` tags and weighted **by length, never by way count** (OSM splits a trail
at every attribute change, so counting ways would call four asphalt slivers plus one
long forest track "mostly asphalt"):

```
[M] Harrachov - Špindlerův mlýn — 21.54 km, +701 m / -644 m
    [one-way, car, lift:chair_lift, transit:bus stop, surface:asphalt 69%]
```

Two gates keep that flag honest. It appears only when **at least half** the route's
length is tagged at all, and it names a surface only when that surface is **at least
40 %** of the route — otherwise it says `surface:mixed (77% known)` rather than letting
a 21 % plurality pose as the answer. `--json` carries the full breakdown plus the
`coverage` fraction. Synthesised routes — `--compose-loops`, `--around`, `--from`/`--to`,
`--via`, `--to-poi` — report it too: they are stitched from contracted graph segments
rather than from relation members, so the tags ride along the graph instead, one per step
of each segment. A leg you walk twice is weighted twice, exactly as its length is counted
twice. `sac_scale` and `trail_visibility` are
deliberately *not* reported:
measured against real data they are mapped on 4 % and 1 % of member ways, and a
difficulty claim that is absent 96 % of the time reads as "easy" rather than "unknown".

`transit_access` answers **"a hike I can reach without a car"**, and each match names
which kind it found (`transit:train halt`, `transit:bus stop`) — the two are very
different promises about actually getting there. Rail gets a generous 1 km radius and a
tram/bus stop a tight 400 m, deliberately: `highway=bus_stop` is mapped along nearly
every rural road, so one generous radius for both would match almost everything and the
filter would stop discriminating. Both are tunable (`HIKE_TRANSIT_RAIL_RADIUS`,
`HIKE_TRANSIT_STOP_RADIUS`). An area downloaded **before** this feature carries no
transit data at all: rather than labelling every route unreachable, the search returns
nothing and tells you to re-download the area.

Internally the search is two-pass: cheap geometry/shape/access filters run first
and a long through-route that merely crosses the area is dropped, so the
elevation backend is only queried for routes that already match.

### Via ferrata — find them, or keep clear of them

A via ferrata (*klettersteig*, *sentiero attrezzato*) is a climb equipped with fixed
steel cable, rungs and ladders, walked in a harness clipped to the cable. It is a
different activity from hiking, not a harder hike — and until now this tool drew one as
an ordinary line with a distance and a gain figure and nothing else.

`--ferrata` searches **for** them; `--no-ferrata` keeps **clear** of them:

```
Via ferrata Marino Bianchi — 1.04 km, gain n/a [loop, ferrata 1/2/3 1.0 km]
Sentiero Attrezzato Zumelès — 2.71 km, +145 m / -42 m
    [one-way, surface:gravel 78%, ferrata 1+ 0.7 km]
```

Detection reads two OSM tags, on the route's member ways **and** on the relation itself:
`highway=via_ferrata` and `via_ferrata_scale`. Either one is enough, which is not a
belt-and-braces choice — measured over a box around Cortina d'Ampezzo, 70 ways carry a
grade and only 45 of those are `highway=via_ferrata`; the other **25 are tagged
`highway=path`**. Keying on the path type alone would miss a third of them.

The flag is gated on **presence, never share** — the exact opposite of the surface rule
above. 300 m of cable on a 12 km walk is precisely what must not be averaged away, so any
tagged metre fires it and the measured extent rides alongside rather than instead of it.
Grades are printed raw and never ranked or bucketed: real data holds `0`, `1+`, `3.5`,
`4+` and OSM uses A–F elsewhere, and there is no ordering safe across those schemes.

**`--no-ferrata` is a filter, not a safety guarantee.** It drops routes *known* to
include cable. Every route the search can return is assembled from `route=hiking` /
`route=foot` member ways, so a cabled section inside one is always described by tags we
already hold — that is what makes the filter complete over the routes on offer. What it
cannot see is cable nobody has tagged, and no amount of fetching fixes that. Measured
live: `Via Ferrata Ivano Dibona ascent parte superiore` survives `--no-ferrata`, because
its relation and all ten of its member ways carry `sac_scale` and no ferrata tag at all.

`--ferrata` also returns dedicated `route=via_ferrata` relations, which **no other search
shows**. They are kept in a list of their own so a cabled climb can never appear in an
ordinary result list, or be stitched into a synthesised loop by `--compose-loops`.

`--show-ferrata` lists an area's cabled lines themselves, with no routes drawn — the
`--show-pois` counterpart, one Overpass call and no elevation lookup:

```
13 cabled line(s): 0 mapped as a via ferrata route, 13 as individual ways
  Sentiero ferrata F. Berti (grade 3, 1.9 km, single way)
  Via Ferrata Strobel (grade 3, 0.8 km, single way)
```

An area downloaded **before** this feature holds no ferrata objects and says so rather
than reporting an empty list. Avoidance still works on such a file — it needs only the
member-way tags — unless the file predates those too, in which case both questions are
refused out loud instead of being answered from nothing. All three frontends say it — the
CLI on stderr, the web UI in a notice beside the results, and MCP in the reply text of
both `find_hikes` and `list_ferrata` — because an unexplained empty list from a
safety-adjacent filter would read as "no safe routes here", which is not a claim the tool
is in a position to make. It is said whether or not routes came back: a file that never
fetched ferrata objects can still return the routes whose own member ways are tagged as
cabled, and a partial answer is where a missing caveat does the most damage.

### Hiking *to* something — churches, ruins, peaks…

Distance and gain say how hard a walk is; they don't say whether it's worth doing.
`--poi` adds the destination: **"a 12 km hike with 400 m of climbing that goes to a
ruin."**

```bash
hike-finder --list-poi-kinds                                # the kinds you can ask for
hike-finder --bbox 50.52 15.15 50.60 15.28 --poi ruins,castle --max-distance 25
```

```
[M] Hrubá Skála (žst.) - Kost (bus) — 14.08 km, +335 m / -313 m [one-way, car]
    (start 50.5504,15.2134, OSM relation 1147930)
    [passes castle "zámek Hrubá Skála" (90 m); ruin "Radeč" (101 m)]
[Ž] Turnov - Kozákov — 12.42 km, +110 m / -252 m [one-way, car]
    (start 50.5947,15.2640, OSM relation 369232)  [passes ruin "Rotštejn" (20 m)]
```

Twenty-eight kinds are available: `church` (any place of worship), `shrine` (wayside
shrines & crosses), `ruins`,
`castle`, `memorial`, `archaeology`, `boundary_stone`, `mill`, `mine`, `peak`, `rock`,
`sinkhole`, `cave`, `spring`, `drinking_water`, `waterfall`, `viewpoint`, `tower`, `tree`
(named trees), `museum`, `artwork`, `hut`, `shelter`, `picnic`, `firepit`, `camp`,
`refreshment`, `toilets`. Repeat `--poi`
or comma-separate them; several kinds are **OR**-ed ("a church *or* a ruin"). Every match
reports the object and **how far off the trail it sits**, so "passes near" and "ends at"
stay distinguishable.

Three kinds are narrower than their OSM tag: `tower` skips towers OSM records as
transmission masts, water towers or chimneys, and `shelter` skips bus shelters. Objects
OSM leaves untyped are kept either way — most real lookout towers carry no type tag at
all, so dropping the untyped would lose them. `tree` is narrower in the other direction:
it asks only for trees that carry a **name**, because `natural=tree` is overwhelmingly
street and garden trees (measured over four Czech regions: 4044 of them, against 72
named).

`--poi-radius M` (default 250, `HIKE_POI_RADIUS_M`) sets how close counts. Distance is
measured to the trail **line**, not to its mapped nodes — a straight stretch drawn with
two nodes kilometres apart still reports its true closest approach.

The filter works in **every** mode — a bbox search, `--compose-loops`, the point-based
modes, and an offline `--area` search — because what a route passes is a property of the
route, not of how it was found. It runs in the cheap pass, so a `--poi` search costs
*less* elevation budget than the same search without it.

**Honesty note**, same register as access: no match means nothing of that kind is *mapped*
in OSM near a route, not that nothing is there. A misspelled kind is a loud error, never a
silent empty result.

> `--poi` **filters** existing routes by what they pass. To have a route **drawn to** the
> nearest ruin instead, see [`--to-poi`](#point-based-route-drawing-pick-points-on-a-map-get-routes)
> below — the same kinds, the opposite question. They combine. To just **see what is
> there**, with no route at all, see [`--show-pois`](#just-show-me-whats-there---show-pois).

### Just show me what's there (`--show-pois`)

Sometimes the question isn't about a walk at all: **"what ruins are around here?"**
`--show-pois` lists the objects themselves — pinned, counted, and exportable — and draws
no routes to any of them.

```bash
hike-finder --bbox 50.52 15.15 50.60 15.28 --show-pois --poi ruins,castle
hike-finder --bbox 50.52 15.15 50.60 15.28 --show-pois --gpx cesky-raj-pois.gpx
hike-finder --area ceskyraj --show-pois --poi viewpoint      # offline, zero network
```

```
7 objects: 3 castles & forts, 4 ruins
  castle "zámek Hrubá Skála" — 50.54930, 15.19710
  castle "Kost" — 50.53390, 15.22860
  ruin "Rotštejn" — 50.57630, 15.24460
  ...
```

The same twenty-eight kinds as `--poi` select what to list; **omit `--poi` to show every
kind** — at real density that can be a lot (the box above returns **957 objects**
unfiltered, 501 of them summits), so the count-by-kind header comes first and nothing is
capped. Results are
grouped by kind and ordered the same way every run. Walk-shaped flags (`--min-gain`,
`--circular`, `--poi-radius`, …) have nothing to act on here; the run names any it ignored
on stderr rather than looking like it filtered.

Two sources, one output: the live `--bbox`, or a downloaded `--area` — the "only in the
area I already have" half, which needs **no network whatsoever**. Either way nothing is
measured, because nothing is walked: it makes **one Overpass call, builds no elevation
provider, and spends nothing from the daily API quota**.

> **A snapshot only knows the kinds that existed when you downloaded it — and it says so.**
> Objects are sorted into kinds at download time, so asking an older `--area` for a kind
> added since can only return an empty list. An empty list reads as "there are none",
> which is a different claim from "this file never looked", so every snapshot **records
> the kind set it was classified against** and the tool diffs it against the registry on
> load: ask an older area for a `tree` and it names the kinds it predates instead of
> answering. `--list-areas` shows the same thing up front (*"2 kind(s) newer than it"*), as
> does the web UI's downloaded-area list. Re-download (`--download`) to close the gap.
>
> One case it can only hedge about: an area saved **between** the arrival of points of
> interest and this mechanism holds objects but no record of which kinds it looked for.
> Such a file reports *"does not record which kinds it was saved with"* whenever it is
> asked a POI question — it cannot do better, and guessing would be the confident empty
> list this all exists to prevent. Re-downloading it once fixes it for good.

`--gpx` / `--geojson` export the listing as **waypoints** (a GPX `<wpt>` per object, a
GeoJSON `Point` per object) rather than tracks — load them into OsmAnd / Komoot / mapy.cz /
a Garmin and navigate to them yourself. An unnamed object exports under its kind
("viewpoint"), never as a blank pin. `--json` gives the same list as structured data.

> Three POI questions, three flags: **`--poi`** filters routes by what they pass,
> **`--to-poi`** draws a route to the nearest one, **`--show-pois`** just lists them.
> `--list-poi-kinds` (formerly `--list-pois`, still accepted) prints the kinds.

**Honesty note:** an empty listing means nothing of that kind is *mapped* in OSM there. A
snapshot downloaded before this feature existed says so explicitly rather than reporting an
empty area, and the objects are **not clipped** to the box — a large object straddling the
edge keeps its representative point wherever it falls, because dropping a real thing is
worse than showing one just outside.

### Near-miss results (close-but-not-matching)

When a query returns little or nothing, the search can also list routes that
*just* miss — each flagged and annotated with **how** it falls short, so a "close"
route is never mistaken for a match:

```text
~ 0402 — 9.86 km, +709 m / -327 m [one-way, lift:chair_lift] (…)  [near miss: gain 709 m — 41 m below the 750 m minimum]
```

A route qualifies when it is within tolerance of a numeric bound (gain within a
percentage, distance within a few km) **or** has parking/a lift just past its
access radius (`nearest parking 380 m away — just past the 300 m limit`).
Shape is never relaxed — a loop is not "almost point-to-point" — and an *excluded*
access stays strict, so near-misses always share the shape and exclusions you
asked for. By default they appear **only when nothing matches** (`auto`); you can
force them always on or off. Tolerances are tunable (see the env vars below).

### Saved areas — fetch once, search offline (no API calls)

Exploring one area with several filters re-hits Overpass and the elevation API
each time. Instead, **download the area once** and search the saved copy offline:

```bash
hike-finder --bbox 50.72 15.58 50.74 15.62 --download krkonose.json   # one fetch + elevation warm-up
hike-finder --area krkonose.json --min-gain 600 --circular            # offline, zero API calls
hike-finder --area krkonose.json --max-distance 8 --car-access        # …re-filter freely
```

`--download` fetches the routes once and computes elevation for **every** plausible
route (it spends the elevation budget up front, since you download before knowing
your filters), saving geometry + elevation to a JSON snapshot. `--area` then runs
the *same* engine against the snapshot with **no network at all** — results are
identical to a live search by construction (validated: offline gains match a live
search byte-for-byte). Only the sample interval is frozen into the snapshot; gain
threshold, smoothing, access radii and shape tolerance stay tunable offline. The
web UI exposes this as a **Download** button + a saved-area selector; MCP gains a
`download_area` tool and an `area` argument on `find_hikes`.

**Seeing what you already have.** `--list-areas` prints the areas already downloaded —
name, bounding box, when, and what is in them — and `--area` accepts a bare *name* as well
as a path:

```bash
hike-finder --list-areas
#   ceskyraj             50.5400,15.1800 .. 50.5700,15.2400
#                        12 routes, 3198 elevation samples, 280 POIs  ·  0.2 MB, downloaded 2026-08-09 12:07Z
hike-finder --area ceskyraj --poi castle          # by name, offline, zero API calls
```

The **web UI outlines every downloaded area on the map** and lists them beside it; click
one to search it offline. MCP gets the same inventory as a `list_areas` tool. Scope: this
tracks the *named* snapshot directory (`HIKE_SNAPSHOT_DIR`, where the web UI downloads);
a file you wrote with `--download some/path.json` isn't registered anywhere — search it
with `--area some/path.json`. An area downloaded before points of interest existed reports
`0 POIs` and is flagged for re-download, so a `--poi` search against it can't be mistaken
for "there is nothing of that kind here"; an area that merely predates some *kinds* is
flagged too, with how many and which.

**Drawing the area.** In the web UI, **Draw a box** lets you drag an exact rectangle rather
than relying on however the map is panned; it shows the size in km before you spend a query
on it. The same box is used for both downloading and searching, so the two can't disagree.
With nothing drawn it falls back to the whole map view.

### Transparent cache (automatic, on by default)

Even without an explicit snapshot, a **transparent on-disk cache** (SQLite, stdlib
only) sits at the two network seams so you don't re-hit the public servers when you
re-run or pan around an area:

- **Elevation** points are cached forever (terrain doesn't change) and — because a
  route relation carries its full geometry regardless of the query box — they're
  reused even across *different overlapping* bounding boxes, not just exact re-runs.
- **Overpass** areas are cached with a time-to-live (`HIKE_OVERPASS_CACHE_TTL_DAYS`,
  default 30 days; trails change slowly).

It's invisible — cached runs return exactly what a live run would — and it's
fail-safe: any cache error degrades to a normal live fetch. Disable it for a run
with `--no-cache` (or `HIKE_CACHE=0`); empty it with `hike-finder --clear-cache`.
This is what makes repeat exploration cheap *and* keeps the tool a polite OSM
citizen. (Unlike a snapshot, the cache isn't a portable file you manage — it's just
plumbing. A `--download` snapshot stays the way to search a fixed area fully offline.)

### Composing loops (stitch connected trails into a day-loop)

Most KČT relations are *linear* marked segments (a coloured trail A→B); a circular
day-hike is usually an ad-hoc combination of several connected segments. So
`circular=true` only finds the few loops mapped as a single relation, and legitimately
returns little. **Compose mode** instead builds one graph from *every* relation's member
ways and searches it for cycles of a target length — synthesising loops that aren't
mapped as a single trail:

```bash
hike-finder --bbox 50.72 15.58 50.74 15.62 --compose-loops \
            --min-distance 5 --max-distance 12 --user-agent you@example.com
```

Each result is stitched from several marked trails, so it has **no single OSM relation
id** — it's rendered with its constituent trails instead:

```text
Composed loop — 9.86 km, +540 m / -538 m [loop, car] (start 50.73,15.61, composed of 0402 + 1801 + Medvědí okruh)
```

The target length comes from `--min-distance`/`--max-distance` (default 3–15 km).
Composed loops are kept **inside the searched bbox** (a loop that would wander out on a
through-route is excluded), so widen the area for longer loops. On a dense area there can
be dozens of candidates; degenerate near-zero-area **slivers** (an out-and-back along two
near-parallel trails) are dropped outright by a compactness floor (`HIKE_COMPOSE_MIN_COMPACTNESS`),
then the tool returns the **15 most loop-like** of the rest (ranked by compactness, so thin
shapes sink — tune with `HIKE_COMPOSE_MAX_LOOPS`) and logs how many distinct loops it found
(and how many slivers it dropped). Elevation, distance, and car/lift access are
computed exactly as for a real route, and a composed loop is circular by construction
(gain ≈ loss). The web UI exposes this as a **"Compose loops from connected trails"**
checkbox; MCP via a `compose_loops` argument on `find_hikes`.

Add `--car-access` (or `--chairlift-access`) to get **"a loop from where I park"**: only
loops reachable from a mapped parking lot / lift survive, each started at that trailhead.
The reachability test runs *before* the compactness cap, so the returned loops are ones
you can actually drive/ride to (otherwise the cap can fill with compact loops far from any
trailhead). The loop geometry — and its gain/loss — is unchanged; only the start moves.

> **Honesty note:** a composed loop is a *suggestion* — it asserts only that these
> connected marked segments form a loop of that length, not that anyone signs or walks it
> as one route. Loop closure itself is high-confidence (exact shared OSM nodes); the
> composition is geometric, not editorial.

> **Use a local DEM for compose.** Composed loops are long (8–15 km), so each one needs
> hundreds of elevation samples. On the **public elevation API** (throttled to ~1
> request/second, batched 100 points/request) a default compose run is **slow** — dozens
> of requests, roughly a minute cold — but it stays well under the daily cap (a default
> 15-loop run is on the order of 50 requests, not 1000). The cap only becomes a real risk
> if you raise `HIKE_COMPOSE_MAX_LOOPS` far past the default or do many runs, in which
> case later loops degrade to `gain n/a`. Either way, for fast, unlimited elevation on
> every composed loop, point it at a
> [local DEM](#two-elevation-backends-both-supported) (`HIKE_ELEVATION_MODE=local`).

### Point-based route drawing (pick point(s) on a map, get routes)

Four modes that take **points instead of a bounding box** — you don't draw a box, you drop
a pin (or two). All derive their own search area from the point(s), so **omit `--bbox`**.

Every `LAT LON` below can be a **place name** instead (`--from "Pec pod Snezkou" --to
"Snezka"`); see [Naming a place instead of typing
coordinates](#naming-a-place-instead-of-typing-coordinates). One flag per point — repeat
`--via` rather than listing several coordinates under one.

**Circular routes near a point** (`--around LAT LON`) — "draw me a ~10 km loop starting
*here*":

```bash
hike-finder --around 50.73 15.60 --min-distance 8 --max-distance 12 \
            --user-agent you@example.com
```

It reuses the loop-composition engine, but anchored to your point: only loops that pass
within `--around-radius` metres of it survive (default 1000; also `HIKE_AROUND_RADIUS_M`),
and each loop is **started at the on-loop spot nearest your point**. Combine with
`--car-access` / `--chairlift-access` to also require a trailhead near the loop.

> **"within a set distance boundary" = total loop *length*, not a geofence.** The
> `--min-distance`/`--max-distance` band (default 3–15 km) sets how *long* the loop is, not
> how far it may stray — a 12 km loop anchored at your point can still roam a few km away.
> The point is where the loop *passes through and starts*, controlled by `--around-radius`.

**N shortest routes between two points** (`--from LAT LON --to LAT LON`) — "how do I walk
from A to B, and what are my options":

```bash
hike-finder --from 50.72 15.58 --to 50.76 15.63 --routes 3 \
            --user-agent you@example.com
```

Each point is snapped onto the nearest marked trail (splitting it at the projected spot, so
a route reaches exactly where you pointed — not the next junction kilometres away), then the
tool draws the **shortest route first, then the next-shortest**, and so on. `--routes N`
(default 3; also `HIKE_ROUTES_K`) sets how many; `--max-distance` caps a route's length.

> **`--routes N` returns N *distinct* routes, not the literal 2nd/3rd shortest.** A candidate
> that re-uses more than `HIKE_ROUTES_OVERLAP_FRAC` (default 0.6) of an already-kept route's
> length is skipped, so you get genuinely different alternatives rather than the same line
> ± one segment. Set `HIKE_ROUTES_OVERLAP_FRAC=0` for literal k-shortest.
>
> **Known limitations.** A point more than ~2 km from any trail (`HIKE_ROUTES_MAX_SNAP_KM`)
> is treated as off-network and yields no routes, rather than silently routing to a distant
> trail. And the fetched area is a corridor padded `max(2 km, 0.4×separation)` around the two
> points (`HIKE_ROUTES_PAD_KM`/`HIKE_ROUTES_PAD_FRAC`): a longer *alternative* that bows well
> outside that corridor can be clipped, so raise those knobs if a detour you expect is
> missing.

**One route linking several points** (`--via LAT LON`, repeatable) — "link *these spots*
into a single walk", and with `--via-loop` "give me a *loop* passing through them that
doesn't retrace itself":

```bash
# An open route through three spots, in the order given:
hike-finder --via 50.72 15.58 --via 50.74 15.61 --via 50.76 15.63 \
            --user-agent you@example.com

# Close it into a circular route that returns by a different way:
hike-finder --via 50.72 15.58 --via 50.75 15.62 --via-loop \
            --user-agent you@example.com
```

Give **two or more `--via` points**; each is snapped onto the nearest marked trail (same
mid-segment snapping as `--from`/`--to`), and the tool draws **one** route linking them in
the order you list them (no reordering — the order is yours). With `--via-loop` it closes
the route back to the first point, routing each leg with the segments already used removed
from the graph, so the loop is **edge-disjoint where the trail network allows** and retraces
only a leg that has no disjoint alternative. The log reports how much of the route retraces
itself (0 % = a clean non-repeating loop); a circuit with no disjoint return is flagged as a
largely out-and-back rather than passed off as a loop.

> **Same off-network / corridor limits as `--from`/`--to`.** A `--via` point more than ~2 km
> from any trail (`HIKE_ROUTES_MAX_SNAP_KM`), or a leg crossing a gap in the network, draws
> no route. The fetched area is padded `max(2 km, 0.4×widest-leg)` around the points
> (`HIKE_ROUTES_PAD_KM`/`HIKE_ROUTES_PAD_FRAC`), so a return that bows far outside that
> corridor can be clipped — raise those knobs if an expected detour is missing. `--min`/
> `--max-distance` still filter the linked route by its total length.

**A route to the nearest ruin** (`--from LAT LON --to-poi KIND`) — "I'm *here*; draw me a
route to the nearest ruin":

```bash
# The three nearest ruins or castles you can walk to from this spot:
hike-finder --from 50.73 15.60 --to-poi ruins,castle --routes 3 \
            --user-agent you@example.com

# Just the nearest pub, looking further afield for it:
hike-finder --from 50.73 15.60 --to-poi refreshment --routes 1 --to-poi-radius 6000 \
            --user-agent you@example.com
```

Same twenty-eight kinds as `--poi` (`--list-poi-kinds`), but they mean something different here.
**`--poi` filters** routes you already found by what they happen to pass; **`--to-poi`
draws** the route to the object. You can use both at once — "a route to the nearest ruin
that also passes a pub" — because they answer different questions.

**Nearest means nearest *along the trails*, not as the crow flies.** A ruin 1 km away
across a gorge with no path to it loses to one 1.4 km away on a marked trail: each
candidate gets its own shortest path over the real trail graph, and the results come back
ordered by the walk. `--routes N` (default 3) says how many destinations; `--to-poi-radius`
(default 3000 m, `HIKE_POI_SEARCH_RADIUS_M`) says how far to look for them.

Each result names what it was drawn to and how far its end lands from it:

```
Route to ruin “Rotštejn” — 4.2 km, +180 m / -95 m [one-way] (start 50.7300,15.6000,
composed of KČT red + KČT blue)  [ends 85 m from the ruin]
```

> **The route ends at the nearest point on a *trail*, not at the object.** That gap is
> measured and always reported — "ends 85 m from the ruin", never "arrives at". The same
> honesty rule as car/lift access applies: no result means nothing of that kind is
> *mapped* in OSM near you, not that nothing is there.
>
> **"Nearest" is checked, not asserted.** Straight-line distance is a lower bound on the
> walk, so anything outside the search radius is provably farther on foot too. When the
> longest route returned is *longer* than that radius, a nearer object could be hiding just
> outside it — and the tool says so rather than leaving the superlative standing. Widen
> `--to-poi-radius` to settle it.
>
> **`--max-distance` sizes the fetch, not just the results.** The area fetched is padded by
> the route length cap, which makes clipping a qualifying route impossible — that is what
> lets "nearest" mean nearest. The cost is that a high `--max-distance` (or a wide
> `--to-poi-radius`) makes a heavy Overpass query. Per destination the default cap is
> 3× the straight-line distance to it, so a ruin you can see does not license an arbitrarily
> long walk unless you ask for one.
>
> **Empty results say which of three things happened** — nothing of that kind mapped within
> the radius, objects found but sitting off the trail network, or reachable only past the
> length cap — because they need three different fixes.

All four modes are **live-map only** and exposed on every frontend: the web UI has a
**Mode** selector (pick "Circular routes near a point", "Routes between two points",
"Route linking several points", or "Route to the nearest church / ruin / peak…" — then
click the map to drop your pin(s), with an *Undo last point* button and a *Close into a
circular route* checkbox for `--via`); MCP has the `circular_routes`, `routes_between`,
`route_via`, and `routes_to_poi` tools. Results carry full computed stats and export to
GPX/GeoJSON like any other route.

The same **Mode** selector also carries "Show points of interest (no routes)" — the
[`--show-pois`](#just-show-me-whats-there---show-pois) browse. Unlike the four routing modes
it works on a **downloaded area** as well as the live map, and MCP exposes it as the
`list_pois` tool.

### Export — GPX / GeoJSON (load into your phone or GPS)

Once a search (live, offline `--area`, or `--compose-loops`) gives you routes you like,
hand them off to the device you'll actually navigate with. `--gpx` / `--geojson` write
the **matched + composed routes** (near-misses included, flagged) to a file *alongside*
the normal output:

```bash
hike-finder --bbox 50.72 15.58 50.74 15.62 --circular --gpx loops.gpx     # text + a GPX file
hike-finder --area krkonose.json --min-gain 600 --geojson picks.geojson    # offline, still exports
hike-finder --bbox 50.72 15.58 50.74 15.62 --compose-loops --gpx day.gpx   # composed loops too
```

- **GPX 1.1** — one `<trk>` per route plus a `<wpt>` at each start (the trailhead you
  drive/ride to). Loads into Komoot, OsmAnd, Gaia GPS, Garmin, **mapy.cz**, …
- **GeoJSON** (RFC 7946) — a `FeatureCollection` of route lines carrying the full computed
  stats in `properties` (gain/loss, distance, shape, access, provenance).

The same two flags export the [`--show-pois`](#just-show-me-whats-there---show-pois) listing
instead, as **waypoints** rather than tracks — a GPX `<wpt>` / GeoJSON `Point` per object —
which is the honest shape when the answer is a set of places, not a walk.

When a route's elevation was computed, the exported track carries the **full per-point
profile** — GPX puts an `<ele>` on every point of one clean walking-order track; GeoJSON
writes 3D `[lon, lat, ele]` coordinates. For a fragmented relation whose legs can't be
stitched into one line, the export instead falls back to the **raw mapped geometry** (every
member way, no elevation) so it keeps all legs and matches the reported distance rather than
shipping a track missing legs. The web UI has **Download GPX / Download GeoJSON** buttons
(and draws the route lines on the map); MCP's `find_hikes` takes a `format:
"gpx"|"geojson"` argument that returns the file as text.

### Naming unnamed routes (reverse geocoding)

Most KČT relations carry a `name` or `ref`, but some carry **neither** and show up as
the synthetic `route/<id>`. Opt in to label those from the **place names at their ends**:

```bash
hike-finder --bbox 50.72 15.58 50.74 15.62 --name-places --user-agent you@example.com
```

```text
Labská → Špindlerův Mlýn — 7.65 km, +312 m / -180 m [one-way, car, lift:chair_lift] (start 50.7069,15.6166, unnamed OSM relation 6282997)
```

A point-to-point route reads `<start place> → <end place>`, a loop reads `loop near
<place>`. It's **off by default** (also `HIKE_GEOCODE=1`) because
[Nominatim's usage policy](https://operations.osmfoundation.org/policies/nominatim/) is
strict — so it throttles to ≤1 request/second, sends your contact as the User-Agent, only
looks up the routes that already **matched** (not every candidate), and **caches** every
coordinate so a trailhead is geocoded at most once across runs. A derived label never
overwrites the real OSM `name`/`ref` (those stay truthful in `--json`); the identifier
clause says `unnamed OSM relation <id>` so a geocoded label is never mistaken for a signed
trail name. The web UI exposes a **"Name unnamed routes from places"** checkbox; MCP a
`name_places` argument. Point `HIKE_NOMINATIM_URL` at your own instance for heavy use.

> **Honesty note:** a place-derived label is a *convenience*, not the route's signed name
> (it has none). Offline `--area` searches can't geocode (no network) and say so.

## Two elevation backends (both supported)

| Mode | Source | Setup | Accuracy | Limits |
|------|--------|-------|----------|--------|
| `api` | Open-Elevation / OpenTopoData | none | coarser | rate-limited (per-sec throttle + daily counter, both managed) |
| `local` | SRTM/ASTER GeoTIFF tiles | download tiles once | high | none |
| `auto` | local if available, else api | optional tiles | best available | graceful fallback |

Set via `HIKE_ELEVATION_MODE`. See `src/hike_finder/config.py`.

For `local`/`auto`, drop the GeoTIFF DEM tiles (`*.tif`) for your region in
`HIKE_DEM_DIR`. Multiple tiles are mosaicked through a GDAL **VRT** that is
point-sampled, so only the pixels under each query point are read and memory
stays flat no matter how large the region. The tiles must share a CRS and
resolution (true for a single DEM product); for mixed-resolution sets (e.g.
Copernicus GLO-30 spanning a latitude band, which needs resampling) build your
own with `gdalbuildvrt *.tif mosaic.vrt` and drop the `.vrt` in the directory —
it is used as-is.

## Using it

Three frontends, one engine. **The CLI and web UI need no LLM and no MCP client.**

### Install

Already done in [Getting started](#getting-started-new-machine-from-zero) above;
these are the optional extras.

```bash
pip install -e .                  # base: the `hike-finder` CLI and `hike-finder-web` UI
pip install -e ".[mcp]"           # + the MCP server (`hike-finder-mcp`)
pip install -e ".[local-dem]"     # + the local GeoTIFF DEM elevation backend (needs rasterio)
pip install -e ".[dev]"           # + pytest (run the full offline suite with `pytest`)
```

Extras combine: `pip install -e ".[mcp,local-dem]"`.

### Contact, quotas and rate limits

Everything this tool reads is free and needs no account. Three public services are
involved: **Overpass** (the trail data), **Nominatim** (turning place names into
coordinates, and back), and an **elevation API** (terrain heights) unless you use
local DEM tiles.

**The contact string is identification, not registration.** Overpass and Nominatim
both require a request to name the program and a way to reach its operator, and
both reject the default Python User-Agent — that is the `406` you get without it.
Set `HIKE_OVERPASS_UA` (or `--user-agent`, or the web UI's Contact field) to an
email or URL you actually read.

> **A common misconception: there is no OpenStreetMap signup that gives you more
> API calls.** Registering an account on openstreetmap.org lets you *edit the map*;
> it does nothing for these read APIs. Nominatim's policy offers no paid or
> registered tier at all, and Overpass needs no account — both throttle **per IP
> address**, not per user. So a real contact address doesn't buy you quota. What it
> buys you is a warning email instead of a silent IP block, which is the difference
> between a bad afternoon and a bad month.

The public limits, roughly: Nominatim asks for **at most 1 request/second**;
Overpass considers under ~100 queries and 10 MB/day comfortable for a regularly-run
program. This tool already paces itself inside those (`HIKE_API_MIN_INTERVAL`,
`HIKE_NOMINATIM_MIN_INTERVAL`, `HIKE_API_DAILY_LIMIT`) and backs off on `429`.

Straight from the operators: the
[Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/)
and the [Overpass API wiki page](https://wiki.openstreetmap.org/wiki/Overpass_API).

**How to actually get more headroom**, in order of effort:

| Do this | Effect |
|---------|--------|
| Nothing — the **cache is already on** | Repeat and overlapping searches never re-hit the servers. See [Transparent cache](#transparent-cache-automatic-on-by-default). |
| `hike-finder --place "…" --download myarea.json`, then `--area myarea.json` | **Downloads once, then searches offline forever with zero API calls.** See [Saved areas](#saved-areas--fetch-once-search-offline-no-api-calls). This is the big one. |
| `pip install -e ".[local-dem]"` + DEM tiles in `HIKE_DEM_DIR` | Removes the elevation API from the picture entirely — no quota, and better accuracy. See [Two elevation backends](#two-elevation-backends-both-supported). |
| Point `HIKE_OVERPASS_URL` at a regional Overpass instance | Spreads load off the main server. |
| Run your own Overpass / Nominatim instance | No shared limits at all. Worth it only for heavy or commercial use, which the public servers explicitly ask you not to put on them. |

### Option A — Web UI (easiest; no coordinates to type)

```bash
hike-finder-web                   # serves http://127.0.0.1:8765 (--host/--port to change)
```

Open it, **pan/zoom the map to your area**, fill in the contact field, choose
filters (shape, car/chairlift access, gain and distance ranges), then click
**"Search this map area"**. Matches are listed and pinned at their start point —
click one to jump to it. This is the answer to "how do I get a bounding box": you
draw it by moving the map. Pure standard library, no web-framework dependency.

### Option B — Command line

```bash
hike-finder --bbox 50.72 15.58 50.74 15.62 \
            --circular --chairlift-access \
            --user-agent you@example.com
```

`--bbox` is **`south west north east`** (min-lat min-lon max-lat max-lon). The
three boolean filters are **tri-state**: omit = don't care, `--circular` = require,
`--no-circular` = exclude (same for `--car-access` and `--chairlift-access`).
Numeric filters: `--min-gain`/`--max-gain` (m), `--min-distance`/`--max-distance`
(km). Add `--json` for machine-readable output. `hike-finder --help` lists all.
Add `--compose-loops` to synthesise loops from connected trails (see
[Composing loops](#composing-loops-stitch-connected-trails-into-a-day-loop)), and
`--gpx FILE` / `--geojson FILE` to also write the results as a track you can load
into a GPS or phone (see [Export](#export--gpx--geojson-load-into-your-phone-or-gps)).
Add `--name-places` to label unnamed `route/<id>` routes from their endpoints' place
names (see [Naming unnamed routes](#naming-unnamed-routes-reverse-geocoding)).

Each match prints as one line:

```text
<name> — <km> km, +<gain> m / -<loss> m [loop, car, lift:chair_lift] (start <lat>,<lon>, OSM relation <id>)
```

The `[...]` flags: `loop`/`one-way`, then `car` and/or `lift:<type>` when access
is mapped near an endpoint.

### Option C — MCP server (drive it from an LLM client)

Needs the `mcp` extra. Register the `hike-finder-mcp` command:

```bash
claude mcp add hike-finder --env HIKE_OVERPASS_UA=you@example.com -- hike-finder-mcp
```

**`.mcp.json` / Claude Desktop config (equivalent):**

```json
{
  "mcpServers": {
    "hike-finder": {
      "command": "hike-finder-mcp",
      "env": { "HIKE_OVERPASS_UA": "you@example.com" }
    }
  }
}
```

Then ask in plain language ("find loop hikes near Špindlerův Mlýn reachable by
chairlift") and the client calls `find_hikes(south, west, north, east, …)` with
the same filters as the CLI — plus `compose_loops` (stitch connected trails into
loops) and `area` (search a snapshot offline). `list_pois` answers the other kind of
question — "what churches/ruins are in this area?" — without drawing a route to any of
them, live or against a downloaded area.

> The server is **validated live**: with `mcp` 1.28 it was driven over real OS
> stdio (`python -m hike_finder.server`) — `list_tools` advertises `find_hikes`,
> and a `find_hikes` call against Špindlerův Mlýn returned real engine-computed
> hikes (e.g. *Špindlerův mlýn - okruh — 1.11 km, +34 m / -34 m [loop, car,
> lift:chair_lift]*). It is also pinned offline by `tests/test_server.py` (the
> real MCP protocol over an in-memory session). The SDK's decorator API has
> shifted across versions — if the server won't start, check the imports in
> `src/hike_finder/server.py` against your installed `mcp` version.

### Launcher scripts (one file per interface)

Thin wrappers in [`scripts/`](scripts/) start each frontend with a default
Overpass contact already set, then forward your arguments to the entry point
above — so they never go stale. Override the contact by exporting
`HIKE_OVERPASS_UA` first. One file per interface, both shells:

| Interface | Linux / macOS | Windows |
|-----------|---------------|---------|
| CLI | `./scripts/cli.sh --bbox 50.72 15.58 50.74 15.62` | `.\scripts\cli.ps1 --bbox 50.72 15.58 50.74 15.62` |
| Web UI | `./scripts/web.sh` | `.\scripts\web.ps1` |
| MCP server | `./scripts/mcp.sh` | `.\scripts\mcp.ps1` |

The MCP launcher keeps **stdout clean** (stdout is the JSON-RPC channel), so a
client can point straight at it instead of `hike-finder-mcp`:

```bash
claude mcp add hike-finder -- /abs/path/to/scripts/mcp.sh
# Windows: ... -- powershell -NoProfile -ExecutionPolicy Bypass -File C:\path\to\scripts\mcp.ps1
```

All three are pinned by `tests/test_launchers.py` (the MCP one via a real stdio
handshake — the check that proves nothing leaked to stdout).

### Naming a place instead of typing coordinates

Every mode takes a **place name** wherever it takes numbers, so you need not open a
map at all:

```bash
# An area, by name — instead of four bbox corners:
hike-finder --place "Spindleruv Mlyn" --circular --max-distance 10

# Points, by name — anywhere --around / --from / --to / --via take LAT LON:
hike-finder --from "Pec pod Snezkou" --to "Snezka" --routes 3
hike-finder --around "Snezka" --min-distance 6 --max-distance 12
hike-finder --via "Pec pod Snezkou" --via "Snezka" --via-loop
```

The name is looked up once through Nominatim (the same service, and the same cache,
that `--name-places` uses in the opposite direction), and **what it resolved to is
always printed** — the place, its country, and the ground actually searched:

```
Area: Špindlerův Mlýn, okres Trutnov, …, Česko (50.7256, 15.6068) — searching 11.6 x 12.3 km
From: Pec pod Sněžkou, okres Trutnov, …, Česko (50.6936, 15.7336)
```

Two things it will not do quietly:

- **Ambiguity is listed, not guessed.** "Sněžka" names the famous summit *and* a hill
  in Vysočina; "Lhota" names dozens of villages. The first match is taken and the rest
  are printed — pick another with `--place-index N`.
- **A point-sized place is widened, and says so.** OSM maps a summit as a box a few
  metres across; searching that returns nothing for no visible reason. Anything under
  `HIKE_PLACE_MIN_KM` (2 km) is grown to it, and the line reads *"mapped extent 0.01 x
  0.01 km, widened to 2.0 x 2.0 km"*. Use `--place-radius KM` to set the size yourself
  — a village widened to the valley around it, or a whole region narrowed to a walkable
  part.

These lines go to **stderr**, so `--json` output stays machine-readable.

Over MCP the same arguments exist on every tool — `place`, `place_radius_km`,
`place_index` on the area tools, and `place` / `start_place` / `finish_place` (and
`{"place": …}` waypoints for `route_via`) on the point tools. The reply carries the
resolution as a trailing block, so an LLM that meant the *other* Lhota can see that it
did and correct itself.

### Getting a bounding box (CLI / MCP)

You can still give the four corners yourself, in the order **`south, west, north,
east`** (min latitude, min longitude, max latitude, max longitude). The web UI gives
you the box for free; otherwise:

- **openstreetmap.org → "Export" tab** draws a draggable box and shows its four
  edges — copy them straight in.
- Or read the corners off **mapy.cz** for the area you're planning.

> **Example** — the bbox `50.72,15.58,50.74,15.62` (Špindlerův Mlýn) returns ~11
> routes, each flagged for `car`/`lift`/shape with a locally computed gain/loss;
> the detected loop *Špindlerův mlýn – okruh* reads **+34 m / −34 m** (gain ≈ loss,
> as a closed loop must — the pipeline's built-in sanity check). The **start** pin
> is coupled to access where possible: with a mapped parking/lift near an end,
> `start` is the terminus nearest it, so it usually lands on the trailhead you'd
> drive or ride to. See [`HANDOFF.md`](HANDOFF.md) for how each piece was validated.

### Configuration (environment variables)

All optional except where noted; defaults come from `src/hike_finder/config.py`.

| Variable | Meaning | Default |
|----------|---------|---------|
| `HIKE_OVERPASS_UA` | User-Agent for Overpass — **required by the public server**; use a real contact | generic UA naming no contact |
| `HIKE_OVERPASS_URL` | Override the Overpass endpoint (use a regional/self-hosted instance for heavy use) | `overpass-api.de` |
| `HIKE_ELEVATION_MODE` | `api` \| `local` \| `auto` | `auto` |
| `HIKE_DEM_DIR` | GeoTIFF DEM tile directory (for `local`/`auto`) | — |
| `HIKE_API_ENDPOINT` | Override the elevation API endpoint | provider default |
| `HIKE_API_MIN_INTERVAL` | Min seconds between elevation-API requests (keeps you under the public ~1 req/sec limit) | `1.1` |
| `HIKE_API_MAX_RETRIES` | Retries on transient API errors (429 / 5xx / network), with exponential backoff honouring `Retry-After` | `3` |
| `HIKE_API_BACKOFF` | Backoff base seconds, doubled each retry | `2.0` |
| `HIKE_API_MAX_BACKOFF` | Cap on any single wait, seconds; a `Retry-After` above this (e.g. a daily-quota 429) makes the route degrade to `n/a` instead of stalling | `30` |
| `HIKE_API_DAILY_LIMIT` | Max elevation-API requests per UTC day, counted in a persistent file across runs; at the cap, routes degrade to `n/a` instead of getting the IP banned. `0` disables tracking | `1000` |
| `HIKE_API_STATE_DIR` | Directory holding the daily-counter file | per-user cache (`%LOCALAPPDATA%/hike-finder` or `~/.cache/hike-finder`) |
| `HIKE_GAIN_THRESHOLD` | Hysteresis climb threshold, metres (must exceed peak-to-peak DEM noise) | `10` |
| `HIKE_SAMPLE_INTERVAL` | Resample spacing along the track, metres | `25` |
| `HIKE_SMOOTH_WINDOW` | Elevation smoothing window, samples | `3` |
| `HIKE_LOOP_TOLERANCE` | start≈end distance that closes a loop, metres — a ceiling on a 5 %-of-route-length bound, so it is the whole test only above 3 km | `150` |
| `HIKE_CAR_RADIUS` | Parking-near-endpoint radius, metres | `300` |
| `HIKE_LIFT_RADIUS` | Lift-station-near-endpoint radius, metres | `400` |
| `HIKE_MAX_ROUTE_FACTOR` | Drop routes longer than this × the bbox diagonal (kills through-routes) | `4.0` |
| `HIKE_NEAR_MISS_GAIN_FRAC` | Near-miss gain tolerance, as a fraction of the bound (0.2 = within 20%) | `0.2` |
| `HIKE_NEAR_MISS_DIST_KM` | Near-miss distance tolerance, km past a min/max | `2.0` |
| `HIKE_NEAR_MISS_RADIUS_FRAC` | Near-miss access tolerance: parking/lift within radius × (1 + this) still counts | `0.5` |
| `HIKE_SNAPSHOT_DIR` | Directory for named area snapshots saved by the web UI | per-user cache (`…/hike-finder/snapshots`) |
| `HIKE_CACHE` | Transparent on-disk cache of Overpass + elevation results, so repeat/overlapping searches don't re-hit the public servers. `0`/`false`/`no`/`off` disables (same as `--no-cache`) | on |
| `HIKE_CACHE_DIR` | Directory for the cache SQLite file | per-user cache (`…/hike-finder`) |
| `HIKE_OVERPASS_CACHE_TTL_DAYS` | How long a cached Overpass area stays fresh, days (trails change slowly). `0` disables Overpass caching; elevation is immutable terrain and never expires | `30` |
| `HIKE_GEOCODE` | Opt-in reverse-geocode naming of **unnamed** routes (`route/<id>`) from place names via Nominatim (same as `--name-places`). Off by default — Nominatim's policy is strict | off |
| `HIKE_NOMINATIM_URL` | Override the Nominatim reverse-geocoding endpoint (self-host for heavy use) | `nominatim.openstreetmap.org` |
| `HIKE_NOMINATIM_SEARCH_URL` | Override the Nominatim **forward** (`/search`) endpoint used by `--place`. Defaults to the sibling of `HIKE_NOMINATIM_URL`, so self-hosting one direction moves both | derived |
| `HIKE_PLACE_MIN_KM` | `--place`: smallest area a named place may be searched as, km across. A summit or a hut is mapped as a point-sized box; searching that literally returns nothing, so a smaller extent is widened to this — and the frontend says it widened it | `2` |
| `HIKE_PLACE_MATCHES` | `--place`: how many candidate places to fetch, so an ambiguous name can list its alternatives instead of silently picking one | `5` |
| `HIKE_NOMINATIM_MIN_INTERVAL` | Min seconds between Nominatim requests (the public server caps at ~1 req/sec) | `1.1` |
| `HIKE_GEOCODE_CACHE_TTL_DAYS` | How long a cached place name stays fresh, days (place names change slowly). `0` disables geocode caching | `365` |
| `HIKE_COMPOSE_MIN_KM` | Compose mode: default min loop length when no `--min-distance` | `3` |
| `HIKE_COMPOSE_MAX_KM` | Compose mode: default max loop length when no `--max-distance` | `15` |
| `HIKE_COMPOSE_MAX_SEGMENTS` | Compose mode: max trail segments stitched per loop | `12` |
| `HIKE_COMPOSE_OVERLAP_FRAC` | Compose mode: drop a loop sharing more than this fraction of its length with an already-kept loop (near-duplicate collapse) | `0.6` |
| `HIKE_COMPOSE_MAX_LOOPS` | Compose mode: max loops returned, ranked by compactness (roundest first); bounds the per-loop elevation cost | `15` |
| `HIKE_COMPOSE_MIN_COMPACTNESS` | Compose mode: drop a loop below this Polsby–Popper compactness (4πA/P²) — a degenerate thin sliver, not a real loop; `0` disables | `0.05` |
| `HIKE_ROUTES_MAX_FACTOR` | `--from`/`--to` mode: cap a route's length at this factor × the straight-line separation when no `--max-distance` is given, so the k-shortest search can't wander far on a dense graph | `3.0` |
| `HIKE_POI_RADIUS_M` | How close a route must pass to a `--poi` object (church, ruin, peak…) to count as reaching it, metres. Measured to the trail line | `250` |
| `HIKE_POI_SEARCH_RADIUS_M` | `--to-poi` mode: how far from the start to look for destinations, metres. Also sizes the fetched area, so raising it makes the query heavier | `3000` |

> **Snapshot caveat:** `--area` locks the snapshot's sample interval (the saved
> elevation points were taken at it), so `HIKE_SAMPLE_INTERVAL` can't break an
> offline search. `HIKE_MAX_ROUTE_FACTOR` is the one knob that still applies
> offline; the download already prunes over-length routes, so loosening it offline
> is safe and tightening it only drops a subset.

### Troubleshooting

- **`406 Not Acceptable` / every Overpass request fails** → set `HIKE_OVERPASS_UA`
  to a real contact. The public server rejects the default Python User-Agent. See
  [step 5 of Getting started](#5-tell-openstreetmap-who-you-are-one-line--skip-it-and-nothing-works)
  and [Contact, quotas and rate limits](#contact-quotas-and-rate-limits).
- **No hikes returned** → widen the bbox or loosen the filters. Note that loops are
  genuinely sparse in KČT data (most relations are linear marked segments), so
  `circular=true` legitimately returns few results — try `--compose-loops` to stitch
  connected trails into loops instead (see
  [Composing loops](#composing-loops-stitch-connected-trails-into-a-day-loop)).
- **`--compose-loops` returns few/no loops** → the target loop must fit *inside* the
  searched bbox; widen the area or the `--min/--max-distance` band.
- **Slow / occasional `504`** → public Overpass overload; the client retries with
  backoff. Point `HIKE_OVERPASS_URL` at a regional instance for heavy use.

## Status

The whole pipeline — geometry/gain/access math, the Overpass parser, both
elevation backends (API with rate-limit throttle, retry/backoff, and a persistent
daily-request counter; local DEM via a point-sampled GDAL VRT), the transparent
cache, loop composition, offline snapshots, near-misses, reverse-geocode naming,
GPX/GeoJSON export, point-based route drawing, the points-of-interest destination
filter, routing *to* the nearest such object, the points-of-interest inventory,
the downloaded-area inventory, public-transport access, the length-weighted
surface report, and via ferrata (find / avoid / list) — is **implemented, unit-tested
(offline), and validated live** across all three frontends (CLI + web + MCP), with
computed gain cross-checked against the loop invariant (gain ≈ loss). Released as v0.7.0. See
[`CHANGELOG.md`](CHANGELOG.md) for the per-release breakdown and
[`HANDOFF.md`](HANDOFF.md) for the architecture and open design notes.

TDQS

A4.5/5.0

Scored across 1 tool

Disambiguation5/5

Only one tool exists, so there is no possibility of confusion between tools. The agent will always choose the correct tool.

Naming Consistency5/5

With a single tool, naming consistency is automatically maintained. The verb_noun pattern is clear and descriptive.

Tool Count3/5

The server has only one tool, which feels thin for a typical MCP server. While the tool is comprehensive, a single tool often suggests missing functionality or potential for better scoping.

Completeness4/5

The tool covers the core 'find hikes' use case with many filters (elevation, distance, circular, access). Minor gaps exist, such as lacking a tool to retrieve detailed info for a specific hike or to manage user preferences, but the search functionality is robust.

Maintenance

ActivityMaintained
ResponsivenessNo issues