# PlaceRoot (pypi · placeroot)

Grounds AI agents in Overture Maps open data — compact answers, no API key.

- Trust score: 64/100 (medium)
- Change this week: 0
- Registry status: active
- Liveness: live
- Owner verified: no
- Last scored: 2026-09-20

## Components

- npm · `placeroot`: 50/100, [markdown](https://verifymcp.io/servers/chuofringer-placeroot/placeroot.md), [page](https://verifymcp.io/servers/chuofringer-placeroot/placeroot)
- pypi · `placeroot`: 64/100 (this document), [markdown](https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2.md), [page](https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2)

## Channel facts

- Registry: `pypi`
- Package: `placeroot`
- Version: `0.10.0`
- Transport: `stdio`

## Trust breakdown

How this component scores in each security and reliability category. Every signal is checked automatically from public evidence about the published package, including repeated runs of it in an isolated sandbox, and we only credit what we can confirm. Scores are 0–100 per category. Scoring method: https://verifymcp.io/docs/scoring (what has changed: https://verifymcp.io/docs/scoring/changelog)

Scored 2026-09-20.

- **Supply Chain Security**: 50/100
  - Malware scan not yet available for this package.
  - No known CVEs affecting this package version or its production dependencies.
  - Runs hatchling.build at install time, a recognised native-build step with no shell scripting around it.
  - 1 of 30 dependencies flagged as unhealthy.
- **Provenance & Transparency**: 35/100
  - Source repository is publicly reachable at the declared URL.
  - Provenance check failed: no build-provenance attestation is published.
  - License check failed: no license is declared.
  - Actively maintained (last published 24 days ago).
  - Publishes a security disclosure policy (SECURITY.md).
- **Schema Quality & AI Usability**: 68/100
  - 100% of prompts and resources have a non-trivial description (not blank, and not just the item's name).
  - AI-judged instruction clarity (good).
  - Context-footprint check failed: tool/resource definitions use about 21714 tokens (~482/item across 45 items; 42 tools + 3 resources), over budget; trim descriptions and params.
  - Usage-examples check failed: none of the tools include examples.
- **Stability & Change Management**: 97/100
  - Stability observed for 29 of 30 days with no destabilising changes; credit accrues until the full window elapses.
- **Tool Coverage**: 74/100
  - 100% of tools have a non-trivial description (not blank, and not just the tool's name).
  - 10% of tool parameters carry a description.
  - Structured output schemas are declared (100% of tools); any adoption earns full credit.
- **Tool Safety**: 100/100
  - No prompt-injection markers were found in the server instructions, tool names or descriptions we captured.
  - We read all 42 captured tool definition(s), and no name or description among them implies an irreversible operation.
  - An AI judge read all 44 captured unit(s) of tool text and found none that tries to manipulate the model reading it.
- **Capabilities**: 100/100
  - Implements a current MCP spec version (2026-07-28).

## Install

### How do I install the PlaceRoot MCP server?

PlaceRoot runs locally as a PyPI package, launched with uvx placeroot. Ready-made configuration for Claude, Cursor, VS Code, Codex and 5 more is on this page, copied from each client's own documentation.

### Claude

```bash
claude mcp add chuofringer-placeroot -- uvx placeroot
```

### Cursor

```json
{
  "mcpServers": {
    "chuofringer-placeroot": {
      "command": "uvx",
      "args": [
        "placeroot"
      ]
    }
  }
}
```

### VS Code

```json
{
  "servers": {
    "chuofringer-placeroot": {
      "command": "uvx",
      "args": [
        "placeroot"
      ]
    }
  }
}
```

### Codex

```bash
codex mcp add chuofringer-placeroot -- uvx placeroot
```

### opencode

```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "chuofringer-placeroot": {
      "type": "local",
      "command": [
        "uvx",
        "placeroot"
      ],
      "enabled": true
    }
  }
}
```

### OpenClaw

```bash
openclaw mcp add chuofringer-placeroot --command uvx --arg placeroot
```

### Hermes

```yaml
mcp_servers:
  chuofringer-placeroot:
    command: "uvx"
    args: ["placeroot"]
```

### Netclaw

```json
{
  "McpServers": {
    "chuofringer-placeroot": {
      "Transport": "stdio",
      "Command": "uvx",
      "Arguments": [
        "placeroot"
      ]
    }
  }
}
```

### Vellum

```bash
assistant mcp add chuofringer-placeroot -t stdio -c uvx -a placeroot
```

### Other

```json
{
  "mcpServers": {
    "chuofringer-placeroot": {
      "command": "uvx",
      "args": [
        "placeroot"
      ]
    }
  }
}
```

## Changelog

Every change recorded for this component, newest first. Days that predate change tracking, or that we cannot explain, say so: "we were watching and nothing happened" and "we were not watching" are different claims.

### 2026-09-20 (score 64, +1)

No change was recorded against any check on this day. Stability & Change Management went from 93 to 97. That category is still filling its 30-day observation window: 28 days of observed history at the previous scan, 29 at this one. The score rises as the window fills, whether or not the server changes.

### 2026-09-18 (score 63, +1)

No change was recorded against any check on this day. Stability & Change Management went from 87 to 90. That category is still filling its 30-day observation window: 26 days of observed history at the previous scan, 27 at this one. The score rises as the window fills, whether or not the server changes.

### 2026-09-16 (score 62, +1)

No change was recorded against any check on this day. Stability & Change Management went from 80 to 83. That category is still filling its 30-day observation window: 24 days of observed history at the previous scan, 25 at this one. The score rises as the window fills, whether or not the server changes.

### 2026-09-15 (score 61, −3)

- [functional] Stability: pass → 0.80

### 2026-09-13 (score 64, −15)

- [security regression] Malware scan: pass → unverified
- [security] Stability: 0.97 → pass

### 2026-09-12 (score 79, +16)

- [security improvement] Malware scan: unverified → pass

### 2026-09-10 (score 63, +1)

No change was recorded against any check on this day. Stability & Change Management went from 87 to 90. That category is still filling its 30-day observation window: 26 days of observed history at the previous scan, 27 at this one. The score rises as the window fills, whether or not the server changes.

### 2026-09-08 (score 62, −14)

- [security regression] Malware scan: pass → unverified

## MCP tools (42)

### `find_places` (~1998 tokens)

Find places

Find named places, either near a point or inside an area's boundary.

Three mutually exclusive modes:
\- Point + radius: pass lat and lon (radius_m defaults to 1000m).
  Results are nearest-first, within a circle around (lat, lon).
\- Division polygon: pass division_id (a GERS division id, e.g. one from
  an admin-hierarchy chain) instead of lat/lon. Results are every matching place whose
  point falls inside that division's true boundary polygon — no radius
  to guess, and no circle clipping a coastline or straddling a border.
  Results are ordered by name (there's no reference point to rank
  distance from).
\- Area by name: pass area ("Palo Alto") to get the division-polygon mode
  without first resolving the id yourself. The name is resolved with the
  same ranking geocode/resolve_place use; the resolved division is
  echoed back as "area" on the response so it's clear which one was
  searched. A name matching several equally-ranked divisions returns
  {"error": "ambiguous_area", "candidates": [...]} listing their
  division_ids rather than silently picking one, and an unresolvable
  name returns {"error": "not_found"} rather than an empty result that
  would read as "this place has no coffee shops".

category matches Overture's taxonomy (e.g. 'coffee_shop', 'restaurant',
'grocery'); name is a substring match on the place name — both compose
with either mode. Results include operating_status ("in business" /
"permanently closed" / null when unknown) — a business-lifecycle
signal, NOT opening hours; this data has no open-now information —
and a compact trust_note calibrated from confidence and that status.

min_confidence (0.0-1.0) keeps only rows whose confidence score is at
least that value; out-of-range values return a bad_request error.
operating_status filters to a single status (see the schema's enum for
accepted relabeled/raw values); "permanently closed"/"closed" also
match Overture's separate "closed_permanently" raw value, since both
relabel the same way.…

Input parameters:

- `area`
- `brand`
- `categories`
- `category`
- `confirm` (boolean)
- `cursor`: Continuation cursor from a previous truncated answer; valid for the same query on the same data release.
- `detail`: Row detail tier for find_places rows: 'ids' (id + distance_m only), 'compact' (id/name/category/lat/lon/distance_m/trust), or 'full' (every field, incl. trust_note prose). Default: compact.
- `division_id`
- `group_by_category` (boolean)
- `has_phone`
- `has_website`
- `lat`
- `limit` (integer)
- `lon`
- `min_confidence`
- `name`
- `operating_status`: Business-lifecycle status filter (relabeled or raw Overture value, case-insensitive). Default: no filter.
- `radius_m` (number)
- `within`

### `summarize_area` (~182 tokens)

Summarize area

Summarize what's in an area: total places and top categories.

Give the center as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
\`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

Returns a structured {"error": ...} instead of raising if upstream is
unavailable or the dataset is missing columns this tool depends on.

Input parameters:

- `lat`
- `lon`
- `radius_m` (number)
- `where`

### `place_details` (~400 tokens)

Place details

One place, in full: addresses, websites, phones, socials, brand,
source attribution, GERS id, confidence, operating status, and a
compact trust_note.

Resolve either by GERS id (the `id` field find_places and other tools
return) or by name + lat/lon (nearest name match within radius_m of
that point). Pass id, or pass name together with lat and lon — not
both. Long array fields (addresses, websites, phones, socials, sources)
are capped and never silently dropped: a truncated field carries a
matching "<field>_omitted_count". Returns {"error": "not_found", ...}
if nothing matches, or a structured {"error": ...} if the upstream
dataset is unavailable or missing columns this tool depends on.

When looking up by id, also pass near_lat/near_lon — the lat/lon from
the find_places (or other tool) row the id came from — so the lookup
can be narrowed to a ~50km box instead of scanning the whole dataset.
Ignored when resolving by name. Omitting it still works, just slower on
a cold, uncached id.

lang (#410) requests Overture's language-tagged name variant for this
place, when the data has one: `name` becomes the variant and
\`name_primary` is added only when it differs. Default: the stored
\`preferences()` lang, else the primary name unchanged. Never invented
or transliterated.

Input parameters:

- `id`
- `lang`: Result-language code (2-3 lowercase letters, e.g. "de"). Overture-tagged name variants only — never transliterated or invented. Default: stored preference, else the primary name.
- `lat`
- `lon`
- `name`
- `near_lat`
- `near_lon`
- `radius_m` (number)

### `within_distance` (~379 tokens)

Check within distance

Is the nearest place matching category/name within max_distance_m of (lat, lon)?

Give the center as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
\`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

max_distance_m is required and must be a positive number of meters — a
zero, negative, non-finite, or missing value returns {"error":
"bad_request"} rather than silently searching a 0m (or omitted from a
call entirely, in which case the schema itself rejects it before this
tool ever runs) window and answering a confident-looking "false".

Returns {"within": bool, "nearest": {...place row with id...} | None,
"distance_m": float | None}. nearest is None if nothing matches within
a search window capped at max_distance_m * 2 — a real match further out
than that isn't found (documented, not a bug: keeps the search bounded).
name is a literal substring match only — no alt-spelling or typo
fallback applies here, so a misspelled name is an honest "no match",
never a silent yes about a different name.
Returns a structured {"error": ...} if upstream is unavailable or the
dataset is missing columns this tool depends on.

Input parameters:

- `category`
- `lat`
- `lon`
- `max_distance_m` (number, required)
- `name`
- `where`

### `distance_matrix` (~396 tokens)

Distance matrix

Straight-line (great-circle) distance in meters between every origin and destination.

origins and destinations are each a list of LocationRefs — a {"lat":
..., "lon": ...} dict, a GERS id, or a free-text place name, mixed
freely — capped at 10 each (100 pairs max). This is a plain haversine
calculation, not a routed distance or travel time, so it's cheap but it
is NOT what Google/Mapbox distance-matrix APIs return: no roads, no
turns, no travel time. For "how far can I get in N minutes" use
isochrone() instead; for actual routed times/distances between several
points use travel_time_matrix().

An id/name that failed to resolve returns an indexed error
(origins[i]: ... or destinations[i]: ...) with candidates on ambiguity
— checked after the 10-point cap, so an over-cap list always fails on
the cap first. Any origin/destination given by id/name adds "resolved":
{"origins": [{"index", "name", "id", "lat", "lon", "matched_by"}, ...],
"destinations": [...]} covering just those entries; each side is
present only if it had a string entry, and the whole key is absent when
every point was already coordinates.

Returns {"elements": [{"origin_idx": 0, "dest_idx": 0, "distance_m":
812}, ...]}, flat and origin-major (all destinations for origin 0,
then origin 1, ...), budgeted like every other tool. Empty origins or
destinations returns {"elements": []}. Returns a structured {"error":
"bad_request", ...} instead of raising if either list exceeds 10
points or a point is missing/non-numeric lat or lon.

Input parameters:

- `destinations` (array, required)
- `origins` (array, required)

### `meeting_point` (~958 tokens)

Meeting point

Where several people should meet, fairly: candidate venues ranked by
equalized travel time, not geometric distance.

Fairness objective: minimize the MAXIMUM per-person travel time to the
venue ("no one gets screwed"), tie-broken by the smaller spread
(max - min across everyone), then by the smaller total. This is
deliberately not "minimize the average" — that objective can strand
one person with a long trip so two others get a short one.

origins is 2-5 points, each a {"lat": ..., "lon": ..., "mode": ...}
dict, a GERS id, or a free-text place name, mixed freely — mode is
"walk", "cycle", or "drive", defaulting to "walk" when omitted (a
string origin always gets the default mode; give a dict with "mode" to
pick otherwise), and can differ per person (e.g. one driving, one
walking). An id/name that failed to resolve returns an indexed error
(origins[i]: ...) with candidates on ambiguity. Any origin given by
id/name adds "resolved": [{"index", "name", "id", "lat", "lon",
"matched_by"}, ...] for just those origins; absent when every origin
was already coordinates. category optionally filters candidate venues
to an Overture taxonomy slug (e.g. 'coffee_shop'); a wrong or
unrecognized slug is a silent zero-match, not an error.

Method: a seed center is computed from each origin's implied
straight-line travel time (not raw distance, so a walking participant
pulls the center toward them more than a driving one at the same
distance), venues are searched for near that seed, and each
candidate's real per-person times come from routing.route() — the
exact routed number, not the seed's approximation. The total routed
(candidate, origin) fan-out is capped at 16 pairs, regardless of
\`limit` — 8 candidates at 2 origins, down to 3 candidates at 5.

Returns {"center": {"lat", "lon"}, "candidates": [{"id", "name",
"category", "lat", "lon", "per_person": [{"origin_idx", "mode",
"travel_time_min", "distance_m"}, ...], "max_travel_time_min",
"spread_min"}, ...]}, ranked fairest-first, ca…

Input parameters:

- `category`
- `confirm` (boolean)
- `limit` (integer)
- `origins` (array, required)

### `travel_time_matrix` (~672 tokens)

Travel time matrix

Routed travel time + distance between every origin and destination, by mode.

origins and destinations are each a list of LocationRefs — a {"lat":
..., "lon": ...} dict, a GERS id, or a free-text place name, mixed
freely — capped at 5 each (25 pairs max). Unlike distance_matrix's plain
haversine, this is a real shortest-path search over Overture's open
street graph — roads, one-ways, and each mode's own speed model, the
same cost model route() uses for a single pair, one mode per call;
omit mode to use the stored preferences mode, else walk.

An id/name that failed to resolve returns an indexed error
(origins[i]: ... or destinations[i]: ...) with candidates on ambiguity
— checked after the 5-point cap. Any origin/destination given by
id/name adds "resolved": {"origins": [{"index", "name", "id", "lat",
"lon", "matched_by"}, ...], "destinations": [...]} covering just those
entries; each side present only if it had a string entry, absent when
every point was already coordinates.

Reuses a single cached street graph across every origin and
destination when every origin-destination pair fits the mode's
straight-line cap and the whole point set fits one extraction circle,
running one Dijkstra per origin against every destination at once
rather than a search per pair — for a same-city matrix this costs
about what a single isochrone does, not one route() call per pair.
When the points are too spread out for one shared graph, falls back to
a route() call per pair (up to 25).

Returns {"mode", "elements": [{"origin_idx", "dest_idx", "duration_min",
"distance_m"}, ...], "durations_note"}, flat and origin-major like
distance_matrix. durations_note says these are speed-model estimates
over the open street graph, not live traffic. An unroutable pair (off
the street network, or on a disconnected fragment of it) gets
{"duration_min": null, "distance_m": null, "note": "unroutable"}
instead of failing the whole call; if every pair in the matrix is
unroutable the response also carrie…

Input parameters:

- `destinations` (array, required)
- `mode`: Travel mode. Default: stored preference, else walk.
- `origins` (array, required)

### `suggest_areas` (~770 tokens)

Suggest areas

Where within reach: neighborhoods ranked by travel budget + amenities.

The inverse of every other area tool — instead of "describe this place",
"find me a place". anchors is 1-3 {"lat", "lon", "mode"?, "minutes"?}
points (mode: walk/cycle/drive, default from stored preferences;
minutes: default 15). requirements is 1-8 free-text amenity/character
strings, scored the same way as area_score.score_locality — "parks",
"groceries", "coffee shop" resolve against the Overture taxonomy;
a subjective phrase ("quiet streets", "safe neighborhood", "good
schools") comes back {"measurable": false} rather than a guessed score
(see "honesty" in the response).

Method: the same street-graph reach analysis behind PlaceRoot's other
travel-time tools computes each anchor's reachable shed; with more than
one anchor, the sheds are intersected (a candidate must be reachable
within EVERY anchor's own time budget, not just one — "office" and
"gym" both mean both). divisions.divisions_in_polygon (#348) partitions
the (intersected) shed into candidate neighborhoods/localities; each
candidate is scored against requirements the same way
area_score.score_locality (#349) does. Returns {"anchors": [...],
"results": [{"division_id", "name", "subtype", "overlap_fraction",
"lat", "lon", "travel": [{"anchor_idx", "mode", "minutes_budget",
"travel_time_min", "distance_m"} or {..., "note": "unroutable"/
"no_graph_nearby"/...}, ...], "requirements": [...], "overall_score",
"reason"}, ...], "honesty"}, ranked by overall_score (unmeasurable-only
candidates sort last, never dropped) then overlap_fraction, capped at
\`limit` (1-10, default 5). division_id is a stable GERS id — chain a
result into admin_lookup or summarize_area for more detail without
re-running the search. No polygons in the response by default.

An empty "results" list is a valid answer (e.g. two anchors' sheds don't
overlap at all, or nothing in the reachable area is a neighborhood/
locality) with a "note" saying which. A per-anchor trav…

Input parameters:

- `anchors` (array, required)
- `confirm` (boolean)
- `limit` (integer)
- `requirements` (array, required)

### `compare_areas` (~834 tokens)

Compare areas

Compare 2-5 areas side by side: category mix, density, and what differs.

areas is a list of centers sharing one radius_m, each a {"lat": ...,
"lon": ...} dict, a GERS id, or a free-text place/area name, mixed
freely — a named area compares the same radius_m circle around its
resolved point as a coordinate would (not its actual boundary; that's a
later feature). An id/name that failed to resolve returns an indexed
error (areas[i]: ...) with candidates on ambiguity. Any area given by
id/name adds "resolved": [{"index": i, "name", "id", "lat", "lon",
"matched_by"}, ...] for just those areas; absent when every area was
already coordinates. Returns per-area total_places, place density per
km^2, and category_counts aligned across areas for the top ~10
categories by combined count, plus "differentiators" — those categories
ranked by how much they differ, relatively, between areas (the fastest
way to answer "how is area A different from area B"). Returns a
structured {"error": ...} if areas isn't 2-5 centers, or if upstream is
unavailable or the dataset is missing columns this tool depends on for
any area (a partial comparison is not returned).

priorities (optional, up to 6) turns the comparison into a scored
verdict: each entry is {"label": your own term for the criterion,
e.g. "competition"; "category": an Overture taxonomy slug, or
"__density__" for overall place density as a foot-traffic proxy;
"prefer": "more" | "fewer"; "weight": 0.1-5, default 1}. Each area's raw
measure per priority is that category's count (or density) within
radius_m — matched exactly against the category taxonomy (slug plus its
descendants, so "park" never counts parking garages) and counted
explicitly even for categories outside the top-10 alignment above; the
per-priority winner is whichever area is better on that raw measure (a
tie has no winner for that priority); each area's verdict score is the
weight-summed share of each priority normalized against the best area
(measure/max for "more",…

Input parameters:

- `areas` (array, required)
- `priorities`
- `radius_m` (number)

### `admin_lookup` (~146 tokens)

Admin hierarchy lookup

Containing admin hierarchy for a point: neighborhood up to country.

Point-in-polygon against Overture's divisions theme. Returns {"chain":
[{"name": ..., "type": "locality", "id": ...}, ...]} smallest division
first (e.g. neighborhood, then locality, county, region, country) — an
empty chain means no division in the active dataset contains the
point, which is a valid answer for remote areas, not an error. Returns
a structured {"error": ...} if upstream is unavailable or the divisions
dataset is missing the geometry column this tool depends on.

Input parameters:

- `lat` (number, required)
- `lon` (number, required)

### `changes_in_area` (~1065 tokens)

Changes in area

What's opened or closed around here since a past Overture release.

Use this for "what's new around here", "what's closed since spring",
or any question with a time dimension — every other tool here answers
against a single, current snapshot of the data; this is the only tool
that compares two.

Area, exactly one of:
\- place: a free-text area name ("Palo Alto"), resolved with this
  server's usual free-text area matching (prominence-ranked, "City,
  ST" suffix aware). The resolved division is echoed back as "area"
  on the response. A name
  matching several equally-ranked divisions returns {"error":
  "ambiguous_area", "candidates": [...]} rather than silently picking
  one; an unresolvable name returns {"error": "not_found"}. If the
  resolved division is too large to diff (bigger than this tool's
  per-side degree cap — countries, large regions), returns a
  {"error": "bad_request"} naming the area and suggesting a smaller
  one (a neighborhood or district instead of a whole city/region) or
  an explicit bbox.
\- min_lon/min_lat/max_lon/max_lat: an explicit bbox (all four
  together, or none) — for when the caller already has coordinates
  rather than a name. Same size cap as the named-area path.

category, when given, filters both releases' scans identically before
diffing — see diff_places; a place that changed OUT of the category
between releases reads as "disappeared" from this filtered view, which
is the correct reading of "restaurants that changed", not a bug.

from_release/to_release: Overture release strings (YYYY-MM-DD.N). Omit
both to diff the previous release against the ACTIVE one — to_release
defaults to release.resolve_release(), the same release every other
tool in this conversation queries (env pins included), and
from_release defaults to the newest listed release older than it: an
adjacent, recent window. Not the oldest release Overture still serves
— years-old releases are schema-drifted enough that most compared
columns NULL out and everything…

Input parameters:

- `category`
- `from_release`
- `limit` (integer)
- `max_lat`
- `max_lon`
- `min_lat`
- `min_lon`
- `place`
- `to_release`

### `summarize_buildings` (~167 tokens)

Summarize buildings

Summarize building footprints in an area: count, footprint area, height/floor coverage, mix.

From Overture's buildings theme (issue #23). Returns count,
total/mean footprint area in m^2, height_known_pct/num_floors_known_pct
(height and floor count are sparse in real Overture data — this reports
coverage rather than pretending every building has a value, with
mean_height_m/mean_num_floors alongside when any are known), and
top_subtypes/top_classes (top 10 each by count). Returns a structured
{"error": ...} if upstream is unavailable or the dataset is missing
geometry/bbox.

Input parameters:

- `lat` (number, required)
- `lon` (number, required)
- `radius_m` (number)

### `buildings_at` (~180 tokens)

Buildings near a point

Nearest building footprints to a point, nearest first.

From Overture's buildings theme (issue #23). Returns {"results": [{id
(GERS), subtype, class, footprint_area_m2, height_m, num_floors,
distance_m}, ...]}. No raw geometry by default (design rule: answers,
not data) — pass include_geometry=true to also get each row's
footprint as GeoJSON, simplified to a small per-row token cap (each row
then also carries geometry_max_deviation_m, reporting what was lost).
Returns a structured {"error": ...} if upstream is unavailable or the
dataset is missing geometry/bbox.

Input parameters:

- `include_geometry` (boolean)
- `lat` (number, required)
- `limit` (integer)
- `lon` (number, required)
- `radius_m` (number)

### `land_use_at` (~271 tokens)

Land use at a point

What kind of land is this: land use and land cover classification at a point.

From Overture's base theme (issue #167) — PlaceRoot's first tool over
base, distinct from the place-search and area-summary tools (those cover
discrete POIs, not the land itself). Returns {"lat", "lon", "land_use":
{"subtype", "class", "name"} or null, "land_cover": {"subtype",
"class"} or null}. No raw geometry (design rule: answers, not data).

null for either field means no polygon of that type covers the point —
coverage is OSM-derived and patchy outside well-mapped cities, so this
is a common, valid answer for a rural or remote point, not an error.
When multiple polygons overlap (Overture nests them, e.g. a park inside
a residential parcel), the smallest/most specific one is returned and
a "note" flags that the pick was made among several valid candidates.
Returns a structured {"error": ...} if upstream is unavailable or a
base-theme dataset is missing geometry/bbox, and {"error":
"bad_request"} for a non-finite or out-of-range coordinate.

Input parameters:

- `lat` (number, required)
- `lon` (number, required)

### `infrastructure_at` (~498 tokens)

Infrastructure near a point

Infrastructure near a point, nearest first: bridges, towers, piers — and street furniture.

From Overture's base theme (issue #179), type=infrastructure — the
built things that are neither buildings nor POIs. Read the data
honestly before trusting an answer: this layer is dominated by street
furniture (street_lamp, bench, waste_basket, bollard, kerb, crossing),
which outnumbers landmark infrastructure roughly 50:1 in a city
centre. An unfiltered query in a dense area returns lamps and benches
and says nothing about whether a bridge is nearby. To ask about
landmarks, filter: subtype/infra_class match Overture's `subtype` and
\`class` columns (case-insensitive substring; infra_class is `class`
under a non-reserved name) — e.g. subtype="bridge", subtype="tower",
subtype="power", infra_class="pier".

Returns {"center", "radius_m", "results": [{"id", "subtype", "class",
"name", "distance_m"}, ...]}, plus "truncated": true, "total_in_range"
and an explanatory "note" whenever more features matched than were
returned. id is the GERS id, usable with other GERS-keyed tools. No raw
geometry (design rule: answers, not data).

Radius search, not containment: most infrastructure is linear or a
bare point, so "what's within radius_m" is the answerable question.
distance_m is measured to the closest point on the feature, not its
centroid — a bridge you are standing on reads ~0 m, not "distance to
the middle of the bridge". radius_m echoes the effective radius, which
may be lower than requested (large values are clamped).

An empty results list is a valid answer, not an error: base-theme
coverage is OSM-derived and patchy, and "no infrastructure within
500 m" is a real finding. Returns a structured {"error": ...} if
upstream is unavailable or the dataset is missing geometry/bbox, and
{"error": "bad_request"} for a non-finite or out-of-range coordinate.

Input parameters:

- `infra_class`
- `lat` (number, required)
- `limit` (integer)
- `lon` (number, required)
- `radius_m` (number)
- `subtype`

### `water_near` (~546 tokens)

Water near a point

Water near a point, nearest first: waterfront check, distance to river/canal/lake.

From Overture's base theme (issue #200), type=water — oceans, bays,
lakes, ponds, reservoirs, rivers, streams, canals, springs, pools.
Returns {"center", "radius_m", "in_range_count", "results": [{"name"
(when named), "subtype", "class", "distance_m", "is_salt"/
"is_intermittent" (only when true)}, ...]}, plus "truncated": true and
a "note" when more matched than were returned. No raw geometry.

distance_m is to the closest point on the feature, not its centroid —
a canal bank you are standing on reads ~0 m. Water gets dense (an
Amsterdam canal district puts hundreds of rows in a 500 m circle),
which is what in_range_count and the filters are for:
subtype/water_class match Overture's `subtype`/`class` columns
(case-insensitive substring; water_class is `class` under a
non-reserved name) — e.g. subtype="canal", subtype="river",
water_class="lake".

"on_water": true plus "water_body" means the point is *inside* a water
polygon — a lake, a reservoir, a river. For oceans and seas that is a
coarse signal: Overture cuts them into 1-degree tiles whose landward
edge covers dry coastal land, so a waterfront building reads as inside
the ocean. Those bodies are reported this way rather than as a bogus
0 m "nearest water" row, and no distance-to-coast is derived from them
(their tile boundaries include phantom cuts through open water), which
also means subtype="ocean" cannot return distance rows. Lakes and
rivers carry none of that: however large, they appear in results with
a real edge distance. The "note" says which case applies.

An empty results list is a valid answer: coverage is OSM-derived, and
"no water within 500 m" is a real finding about an arid or unmapped
place. radius_m echoes the effective radius (large values are
clamped). Returns a structured {"error": ...} if upstream is
unavailable or the dataset is missing geometry/bbox, and {"error":
"bad_request"} for a bad coordinate.

Input parameters:

- `lat` (number, required)
- `limit` (integer)
- `lon` (number, required)
- `radius_m` (number)
- `subtype`
- `water_class`

### `geocode` (~787 tokens)

Geocode a place name

Free-text place name -> ranked candidate locations, from Overture divisions and places.

No Nominatim, no third-party geocoding API. Matches localities,
neighborhoods, regions, and countries by name (exact > prefix >
substring), falling back to named places if that doesn't fill `limit`.
Returns {"results": [{name, type, lat, lon, id (GERS), admin_context,
rank_score}, ...]}, budgeted like every other tool. Returns a structured
{"error": ...} instead of raising if the remote scan fails.

A query with no location context in it at all (a bare place name that
matches no division, e.g. "Blue Bottle Roastery") can't be bounded to a
region, so the places half of the search is skipped rather than
scanning the global places dataset — minutes, not seconds (#105). That
case comes back empty with a "note" saying so and what to do instead.

A misspelled name that matches no division literally ("Berekley", or
"Berekley, CA" — the region suffix is set aside first) gets one
close-spelling retry over the local divisions table (#215); those
results rank below any literal match, carry "matched_by": "fuzzy", and
come with a "note" naming the spelling they were corrected to.

A query that is entirely a postcode ("94110", "1011AB") is answered as
one (#223): one result per country whose address points carry that code,
with "type": "postcode", "country", "address_count" and a null "id" (a
postcode is not a GERS entity). Codes are shared across countries far
more often than not, so the alternates below the top row are real
ambiguity. The accompanying "note" carries the granularity caveat (a
Dutch code is a street block, a US ZIP a district) and the coverage
limits -- including the countries the addresses theme covers but that
carry no postcode values at all, which is why a valid postcode can still
come back empty.

Exonyms work too (#214): names are matched against Overture's ~100
localized alternates as well as its canonical one, so "Munich" answers
München and "Tokyo" answers 東京都. `name…

Input parameters:

- `lang`: Result-language code (2-3 lowercase letters, e.g. "de"). Overture-tagged name variants only — never transliterated or invented. Default: stored preference, else the primary name.
- `limit` (integer)
- `query` (string, required)

### `geocode_batch` (~225 tokens)

Geocode names in batch

Geocode up to 20 free-text queries in one call, one best match each.

Cuts N round-trips of geocode() into one and, more importantly,
shares ONE local divisions name table across the batch (#329) so a
two-name walk is not N cold S3 scans. For each query, keeps only
the top candidate.
Returns {"results": [{"query", "name", "type", "lat", "lon", "id"
(GERS), "rank_score"}, ...]}, one row per query, in input order — a
query with no match gets the standard error envelope {"query",
"error": "not_found", "detail"} instead, and does not fail the rest of
the batch. queries is capped at 20; a longer list returns a structured
{"error": ...} rather than truncating silently. Budgeted like every
other tool. Returns a structured {"error": ...} instead of raising if
the remote scan itself fails.

Input parameters:

- `limit_per_query` (integer)
- `queries` (array, required)

### `search_categories` (~269 tokens)

Search categories

Free text -> valid Overture category slugs, for the `category` param
the place-search and area-summary tools take.

Lookup only — no geo filtering, no upstream dataset dependency; matches
against a bundled snapshot of Overture's places taxonomy (pinned to
schema v1.9.0). Ranks exact slug match > slug prefix > slug substring >
a match on any taxonomy path segment, so close siblings like "cafe" vs
"coffee_shop" both surface rather than one silently winning. If the
whole query matches nothing, falls back to a lexical phrase-intent
match against a curated synonym lexicon (e.g. "fix my cracked phone
screen" -> mobile_phone_repair). Returns {"results": [{"slug", "path",
"confidence"}, ...]} — path is the root-to-leaf taxonomy (e.g.
["eat_and_drink", "cafe", "coffee_shop"]), confidence is 0-1 and
descending, budgeted like every other tool. An empty/whitespace query
returns {"results": []}. limit is clamped to 0-50, matching every
other tool's limit handling (out-of-range values are not an error).

Input parameters:

- `limit` (integer)
- `query` (string, required)

### `resolve_place` (~803 tokens)

Resolve place to GERS id

Free-text place reference -> ranked, typed GERS ids to hold onto.

Turns something like "the Whole Foods on Lamar" or "Travis County" into
stable Overture ids: merges geocode()'s division matches (locality,
region, county, country, ...) with a name-filtered find_places search
(a business or POI), bbox-limited to near_lat/near_lon if given, else to
the ~20km vicinity of the top division match.

\**Split the location out of the query, and pass `city`.** You know
things this server does not: that "san jose airport" means San Jose,
California, that the Eiffel Tower is in Paris, that a user asking about
"BASIS Silicon Valley" means Sunnyvale. This server knows only what
exists at which coordinates in the current Overture release. When the
location arrives inside one string, it has to guess which words are the
place — and it guesses from map data alone, where "san" names a division
in Henan and "palo" names one in Leyte. Given `city="San Jose, CA"` and
\`query="airport"` there is nothing to guess.

A wrong hint costs a miss and a retry, never a wrong answer: `city`
only bounds where the search looks, and the returned rows still come
from the data. Pass `near_lat`/`near_lon` instead when you have real
coordinates — they are the strongest hint of all.

When nothing resolves for want of a location, the reply carries
\`need: "location"` and a `retry_with` sketch rather than only prose,
so the second call can be made without parsing English.

Returns {"results": [{"id" (GERS), "kind": "division" | "place",
"name", "lat", "lon", "match": "exact" | "prefix" | "substring" |
"fuzzy", plus "admin_context" for a division or "category" for a
place}, ...]}, ranked by match tier then prominence ("fuzzy" — reached
by close spelling rather than by containing the query at all, #215 for
divisions and #373 for places — ranking below every literal match).
A place found through #373's alt-spelling/typo fallback additionally
carries "matched_by": "alt_name" | "fuzzy", and a top-level "note"
name…

Input parameters:

- `city`
- `lang`: Result-language code (2-3 lowercase letters, e.g. "de"). Overture-tagged name variants only — never transliterated or invented. Default: stored preference, else the primary name.
- `limit` (integer)
- `near_lat`
- `near_lon`
- `query` (string, required)

### `resolve_place_batch` (~240 tokens)

Resolve GERS ids in batch

Resolve up to 25 GERS ids to compact place rows in one call.

Collapses N place_details(id=...) round-trips into one: for each id,
resolves it via the same lookup place_details uses and keeps only a
compact row — {"gers_id", "name", "category", "lat", "lon"} — not the
full place_details payload (addresses, websites, phones, socials,
sources, brand, confidence, ...). Use place_details for full detail on
a single id. Results are returned in input order; an id that doesn't
resolve gets the standard error envelope {"gers_id", "error":
"not_found", "detail"} instead and does not fail the rest of the
batch. gers_ids is capped at 25; a longer list
returns a structured {"error": ...} rather than truncating silently.
An empty list returns {"results": []}. Budgeted like every other tool.
Returns a structured {"error": ...} instead of raising if the remote
scan fails or the places dataset is missing columns this tool depends
on.

Input parameters:

- `gers_ids` (array, required)

### `gers_lookup` (~392 tokens)

Look up a GERS id

Any GERS id -> what it is, across themes, plus its cheap cross-theme joins.

The reverse of every other tool: hand back an id one of them returned
(a place, a division, or a building) and get the entity it names —
{"id", "theme", "type", "name", "lat", "lon", "summary", "related"} —
without needing to know which theme it came from. summary carries a few
theme-specific fields (place: category, confidence, brand; division:
subtype, country, region; building: class, height, floors); related
carries the containing division, plus the building at the point when
the id is a place. Never geometry.

Also pass near_lat/near_lon — the lat/lon of the row the id came from —
whenever you have them: the lookup is an id scan across up to three
themes, and the hint narrows each one to a ~50km box instead of a
full-theme scan. Omitting it still works, just much slower on a cold id.
The hint *bounds* the search rather than merely ordering it: an id
outside the box comes back not_found with a note saying so, and the
exhaustive lookup is the same call without near_lat/near_lon. Pass a
hint you are sure of, or none at all.

Transportation segment/connector ids are not resolvable yet and come
back as not_found. Returns {"error": "not_found"} if no theme claims
the id, {"error": "bad_request"} for a malformed id (a GERS id is an
opaque token — 32 lowercase hex characters) or an out-of-range hint,
or a structured {"error": ...} if upstream is unavailable.

Input parameters:

- `id` (string, required)
- `near_lat`
- `near_lon`

### `reverse_geocode` (~114 tokens)

Reverse geocode a point

Point -> nearest address (street/number/postcode) and its containing division chain.

Degrades to a divisions-only result (source: "divisions_only", plus a
note) if the addresses theme is unreachable, missing, or has no nearby
coverage — addresses is Overture's newest, least complete theme, so
this is the expected degraded path. Returns a structured {"error": ...}
instead of raising if the remote scan fails outright.

Input parameters:

- `lat` (number, required)
- `lon` (number, required)

### `address_at` (~391 tokens)

Nearest street addresses to a point

Nearest street addresses to a point, nearest first: number, street, unit, postcode.

The address-level counterpart to reverse_geocode (issue #188): where
that returns one collapsed hop plus the admin chain, this returns the
few doorways around the point with the attributes an address lookup
wants. Returns {"results": [{number, street, unit, postcode,
postal_city, address_levels, country, distance_m}, ...]}, capped at 5.
Optional attributes are omitted when the source has no value for them.

No id is returned: Overture documents address ids as not GERS-stable, so
unlike a place/division/building id there is no durable handle to hand
out. For a stable reference to what is at a coordinate, use
reverse_geocode and hold onto the division it names.

Coverage is the thing to read carefully. The addresses theme is
Overture's only alpha theme and covers 39 countries — no UK, Ireland,
India, China, Korea or Russia, no Africa or Middle East, and little of
Latin America outside Brazil, Mexico, Chile, Colombia and Uruguay. An
empty results list is a valid answer, never an error, and always carries
a "note" saying whether the country is outside the theme's coverage
entirely or is covered but had nothing within the search radius. That
country is the one whose division polygon contains the point, so the note
stays correct next to a border; if the lookup behind it cannot run, the
note says so rather than asserting anything about the data.

Returns a structured {"error": ...} if upstream is unavailable, if the
dataset is missing the bbox/street columns this depends on, or for an
out-of-range coordinate.

Input parameters:

- `lat` (number, required)
- `limit` (integer)
- `lon` (number, required)

### `geocode_address` (~533 tokens)

Find a street address

Street address -> coordinates: "1600 Amphitheatre Parkway, Mountain View".

The forward counterpart to address_at, and finer than geocode, which
answers at city/neighborhood granularity and never at a doorway. The
first comma splits the street from the city; a bare integer at either end
of the street part is the house number ("1600 Amphitheatre Parkway",
"Hauptstraße 5"). Pass `number`/`street`/`city` instead if you already
have the parts. Unit/apartment numbers are not parsed.

The city is resolved first and its boundary bounds the search, so a city
that resolves to no boundary — or to something far larger than a city,
like a state — returns an empty list plus a note rather than a scan. If a
same-named runner-up in the same country supplies the boundary instead,
the note names it — the answer is never silently about a different city,
and never about one in another country. Check `anchor` (name, country,
admin_context) to see which one it was. Street names match in either
spelling (Parkway/Pkwy, West/W, NW/Northwest).

Returns {"results": [{number, street, unit, postcode, country,
distance_m, lat, lon}, ...], "anchor": {name, id, country,
admin_context}, "match": "exact"|"nearest_number"|"street"},
deduplicated to distinct number+street+postcode and nearest the city's
own point first. More matches than `limit` adds "truncated",
"distinct_in_range" and a note. `match` is absent only when no street
was scanned at all (no street name, no city, or an unresolved anchor).

A requested number with no address point is never interpolated: when the
street has other numbered points, `results` holds the real nearest known
numbers bracketing the miss instead (`match: "nearest_number"`, each row
its own genuine coordinates, plus a note naming the miss and neighbors)
— never a synthesized coordinate for the missing number. No usable
numbers on the street falls to `match: "street"`, today's empty-plus-note.

Coverage is alpha: 39 countries, no UK, Ireland, India or China. An empty…

Input parameters:

- `city`
- `limit` (integer)
- `number`
- `query` (string)
- `street`

### `reverse_geocode_batch` (~175 tokens)

Reverse geocode points in batch

Reverse-geocode many points in one call, to cut N round-trips down to one.

Accepts at most 20 points; a longer list returns a structured
{"error": "bad_request"} instead of processing anything. Returns one
row per point in `points`, in the same order — each row is whatever
reverse_geocode(lat, lon) returns (address/divisions chain, or a
"divisions_only" degrade — see reverse_geocode's docstring). A
malformed point (missing/non-numeric lat/lon, or a lat/lon out of
range) doesn't fail the whole batch — it yields the standard error
envelope {"lat", "lon", "error": "bad_request", "detail"} in its slot
instead.

Input parameters:

- `points` (array, required)

### `simplify_geometry` (~137 tokens)

Simplify geometry

Simplify a GeoJSON geometry to fit a token budget, reporting what was lost.

Works on caller-supplied GeoJSON (Polygon, MultiPolygon, LineString,
MultiLineString; Points/MultiPoints pass through unchanged). Binary
searches the simplification tolerance until the result fits max_tokens
instead of asking the caller to guess one. Returns {"geometry": ...,
"max_deviation_m": ..., "original_points": N, "kept_points": M}, or a
structured {"error": "invalid_geometry", ...} for malformed input.

Input parameters:

- `geojson` (object, required)
- `max_tokens` (integer)

### `geometry_op` (~816 tokens)

Geometry operations

Geometry math and predicates — one tool, many ops, no Overture scan.

\`op` selects the operation; pass only the params it needs (points are
\`{"lat": ..., "lon": ...}`; `geometry` is a GeoJSON object):

\- `distance(point, point2)` -> `{"distance_m"}` (great-circle haversine distance)
\- `bearing(point, point2)` -> `{"bearing_deg"}` (initial compass bearing)
\- `destination(point, bearing_deg, distance_m)` -> `{"point"}`
\- `midpoint(point, point2)` -> `{"point"}` (great-circle midpoint)
\- `area(geometry)` -> `{"area_m2", "area_km2"}` (Polygon/MultiPolygon)
\- `length(geometry)` -> `{"length_m"}` (LineString/MultiLineString)
\- `bbox(geometry)` -> `{"bbox": [xmin, ymin, xmax, ymax]}` (any geometry)
\- `centroid(geometry)` -> `{"point"}` (any geometry)
\- `buffer(point, radius_m)` -> `{"geometry"}` (Polygon, ~32-vertex circle approximation)
\- `convex_hull(points)` -> `{"geometry"}` (Polygon; points capped at 100)
\- `point_in_polygon(points, geometry)` -> `{"results": [bool, ...]}` (Polygon/MultiPolygon,
  holes honored; points capped at 100)
\- `nearest_point(point, points)` -> `{"index", "distance_m"}` (points capped at 100)
\- `nearest_point_on_line(point, geometry)` -> `{"point", "distance_m", "fraction"}` (LineString)
\- `union(geometry, geometry2)` -> `{"geometry", "area_km2"}` (Polygon/MultiPolygon, either slot)
\- `intersect(geometry, geometry2)` -> `{"geometry", "area_km2"}`, or `{"empty": true, "note"}`
  when the two inputs don't overlap
\- `difference(geometry, geometry2)` -> `{"geometry", "area_km2"}` (geometry minus geometry2),
  or `{"empty": true, "note"}` when geometry2 fully covers geometry

\`buffer`, `convex_hull`, and `union`/`intersect`/`difference` are the
ops that return geometry; that output is simplified to fit the same
token budget `simplify_geometry`'s own default targets, so there's no
need to chain a second call. `union`/`intersect`/`difference` run via
the DuckDB spatial extension already loaded for other tools (see
geometry_setops.py) rather than geo…

Input parameters:

- `bearing_deg`
- `distance_m`
- `geometry`
- `geometry2`
- `op` (string, required): Geometry operation; each takes a different subset of the other arguments — see below.
- `point`
- `point2`
- `points`
- `radius_m`

### `render_map` (~586 tokens)

Render map

Render any result as a shareable one-pager: map, verdict, and stop list.

Writes ONE self-contained HTML file — interactive SVG map (inline CSS/JS,
vector markers with labels and click popups, polygon/line shapes
including reachability output shaped {"polygon": ..., "stats": {...}}),
a composed verdict, per-stop details, a scale bar, and required
attribution. A shape feature's properties may carry "role": "shed"
(soft translucent fill, dashed edge — for travel-time sheds) or
"role": "outline" (no fill, strong edge — for a compared-area boundary);
any other/absent role keeps the default style. Properties may also carry
a short "label" and one-line "callout", rendered as a text chip over the
shape (capped ~40/~80 chars); for the reachability payload, set
role/label/callout at the payload's top level. No CDN, no tile server,
no API key, zero
network requests when opened — a local file the user can send as-is.
Pass `summary` for
the verdict you want on the page (the sentence you'd tell a spouse,
co-founder, or landlord); when omitted a short fallback is composed
from the payload. Written to PLACEROOT_ARTIFACT_DIR (default: alongside
the tile cache directory). The file itself is the artifact; this tool's
response stays small on purpose. Returns {"path", "bytes",
"features_rendered", "skipped_features"} (plus "truncated": True when
applicable) — skipped_features counts rows/features that couldn't be
rendered (missing coordinates, malformed geometry, or dropped past
mapview.MAX_RENDER_VERTICES) rather than failing the call outright. Pass
inline=true to also get the HTML back in the response when it's small
enough to be worth it.

A point in `result` carrying a "class" property gets a contrasting
marker dot when `legend` maps that class to {"label": str, "color":
str?} — pass e.g. {"open": {"label": "Open now"}, "closed": {"label":
"Closed", "color": "#d55e00"}}. A missing color is assigned from a
fixed color-blind-safe palette; an invalid one (not #rgb/#rrggbb hex)
is dro…

Input parameters:

- `inline` (boolean)
- `legend`
- `result` (required)
- `summary`
- `title`

### `isochrone` (~501 tokens)

Reachable area (isochrone)

Isochrone: the area reachable from a point within `minutes`, by mode.

Give the point as lat/lon, or as `where` — a {"lat", "lon"} dict, a
GERS id, or a free-text place name — but not both (and not neither);
either way returns {"error": "bad_request"} naming the choice. A
\`where` given as an id/name adds a compact "resolved": {"name", "id",
"lat", "lon", "matched_by"} to the answer; absent for lat/lon or a
{lat,lon} where.

Builds a street graph from Overture's transportation theme and runs
Dijkstra out to the time budget. Each mode
excludes its own set of unusable road classes (e.g.
drive excludes footway/path/steps; cycle and drive exclude
motorway/trunk... drive itself allows motorways) and respects one-way
restrictions for cycle/drive (walk ignores them). speed_m_s overrides
the mode's default speed model (walk 1.4 m/s, cycle 4.2 m/s, drive
per-edge from Overture's speed_limits or a class-based default table)
with a single constant. Returns {"polygon": <GeoJSON Polygon>, "stats":
{reachable_nodes, max_radius_m, area_km2}, ...}. The polygon traces the
boundary of reached nodes' occupied grid cells (falling back to a
convex hull for very small reachable sets); reachable_nodes/
max_radius_m are always exact, only the drawn polygon shape
approximates, and is decimated/simplified to fit the token budget.

radius_m optionally overrides the auto-derived graph extraction radius
(capped per mode: 5km walk, 15km cycle, 60km drive); passing something
larger than the cap returns a structured error instead of silently
truncating. An unrecognized mode string returns a structured
{"error": "unsupported_mode"}. minutes must be > 0 and radius_m (if
given) must be >= 0, else returns {"error": "bad_request"}.

Input parameters:

- `lat`
- `lon`
- `minutes` (number)
- `mode`: Travel mode. Default: stored preference, else walk.
- `radius_m`
- `speed_m_s`
- `where`

### `route` (~1290 tokens)

Route between two points

Route: shortest-path distance and duration between two points, by mode.

    Compact directions, not turn-by-turn: builds a street graph from
    Overture's transportation theme around the two points and returns
{"distance_m", "duration_s", "mode", "from", "to", "export"} for the
    fastest path — no polyline unless you ask for one. export is the
    pocket handoff: Google/Apple Maps directions URLs built from the same
    two coordinates (URL schemes only — no Maps API, no extra network), a
    GPX 1.1 document, and a printable stop list. Same cost model every routing tool
    uses (walk 1.4 m/s, cycle 4.2 m/s, drive per-edge from Overture's
    speed_limits or a class-based default table). drive's duration is a posted-speed model
    with no live traffic; all modes snap each endpoint to the nearest
    usable street-graph node (real routes rarely start/end exactly on a
    segment).

    Each mode has a straight-line-distance cap on the two points, rejected
    before any graph is built (see routing.ROUTE_MAX_STRAIGHT_LINE_M, derived
    per-mode from the shared graph-extraction radius cap — roughly
    walk 7.5km, cycle 23.5km, drive 95.5km) — real road distance only ever
    exceeds straight-line, so anything past the cap can't produce a route
    worth extracting for anyway; returns {"error": "route_too_long"} with
    the exact cap in "max_distance_m". An unrecognized mode string returns
    {"error": "unsupported_mode"}; non-finite or out-of-range coordinates
    (lat outside [-90, 90], lon outside [-180, 180]) return
    {"error": "bad_request"}. If no usable graph or street node is found
    near either point, returns {"error": "no_graph_nearby"}. If both points
    snap into the graph but no path connects them (e.g. disconnected
    islands of road data), returns {"error": "no_route", "try": ...}
    rather than raising — "try" is a mode-tuned next move (roadmap §4). If
    the extraction graph hit its internal size cap, the result
    carries "truncated…

Input parameters:

- `confirm` (boolean)
- `from_lat` (number, required)
- `from_lon` (number, required)
- `include_elevation` (boolean)
- `include_path` (boolean)
- `mode`: Travel mode. Default: stored preference, else drive.
- `prefer`: Grade preference. Default: none (plain-distance routing).
- `to_lat` (number, required)
- `to_lon` (number, required)

### `elevation_at` (~244 tokens)

Elevation at a point

Ground elevation in meters at a point, from Copernicus GLO-30 (~30 m resolution).

Reads the Copernicus DEM directly from AWS Open Data (no API key, no
third-party elevation service) — the same open-data pattern every other
tool here uses, just a different bucket than Overture's. Nearest-cell
sampling, not interpolated: at ~30 m ground resolution the answer is
"the elevation of the DEM cell containing this point", which can be off
by a few meters from the exact spot on a steep slope.

Returns {"elevation_m": <float>}. No coverage at this point — open
ocean, or a tile the Copernicus release excludes from public
distribution — is a real, non-error answer: {"elevation_m": null,
"note": "..."} explaining why. Returns a structured {"error": ...} for
an out-of-range coordinate, or if the DEM tile can't be fetched
(network/upstream failure).

Attribution: Copernicus DEM © DLR/ESA, accessed via AWS Open Data.

Input parameters:

- `lat` (number, required)
- `lon` (number, required)

### `from_to` (~493 tokens)

Named-place route

Shortest-path walk, cycle, or drive between two places.

Pass each of from/to as a free-text place name, a {"lat", "lon"} dict,
or a GERS id — mixed freely. Do not call geocode(), resolve_place(), or
geocode_batch() first. Plain names resolve in parallel exactly as
before; coordinates pass through untouched. Builds one street graph and
returns distance, duration, export maps/gpx/text, and a "from"/"to"
block carrying whatever the input resolved to (name/id when it was a
name or GERS id, lat/lon always).

If a name matches several equally-ranked places, returns
{"error": "ambiguous_place", "candidates": [...]} instead of picking
a city. If the two ends resolve a city apart, returns
{"error": "too_far"} with the resolved ends and the mode cap rather
than extracting a continent graph. Same per-mode straight-line caps
as a coordinate route (walk ~7.5 km, cycle ~23.5 km, drive ~95.5 km).
An unresolvable name or GERS id returns {"error": "not_found"}; a
malformed from/to (empty string, dict missing lat/lon, wrong type)
returns {"error": "bad_request"} — either way the offending side is
named in "field": "from" | "to". Omit mode to use the stored
preferences mode, else walk.

include_path, include_elevation, and prefer pass straight through to
route() — see that tool's docstring for what each returns/means
("elevation" climb profile, prefer="flat" grade-avoiding preference and
its honest step-free/accessibility caveats).

confirm=true after the user agreed to wait for a first-time street-graph
build (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. A warm or cached graph never needs it.
Omit confirm unless you just asked and they said yes.

Input parameters:

- `confirm` (boolean)
- `from` (required)
- `include_elevation` (boolean)
- `include_path` (boolean)
- `mode`: Travel mode. Default: stored preference, else walk.
- `prefer`: Grade preference. Default: none (plain-distance routing).
- `to` (required)

### `find_near` (~272 tokens)

Find places near a name

Places of a category near a named place or city.

Pass the user's place name as near. Do not call geocode(),
resolve_place(), or geocode_batch() first. One hop for a category
near a named landmark. Resolves near, then searches like a point
find. Returns compact rows (name, category, distance, trust_note)
plus the resolved near (name and coordinates).

If near matches several equally-ranked places, returns
{"error": "ambiguous_place", "candidates": [...]} instead of picking
a city. An unresolvable name returns {"error": "not_found"}; empty
category or near returns {"error": "bad_request"}. radius_m and
limit follow the same clamps as a point search.

A truncated answer carries "cursor" (delegated straight through from
find_places); pass it back with the same category/near/radius_m/limit
to continue. See find_places' docstring for the bad_cursor/release-
mismatch details — they apply here unchanged.

Input parameters:

- `category` (string, required)
- `cursor`: Continuation cursor from a previous truncated answer; valid for the same query on the same data release.
- `limit` (integer)
- `near` (string, required)
- `radius_m` (number)

### `ground_location` (~483 tokens)

Ground a location

One-hop location grounding: where, surroundings, reach, notable.

Answers "orient me at this point" in a single call instead of chaining
a reverse lookup, an area summary, a reachable-area scan, and a
nearby-places search. Give the point as lat/lon, or as `where` — a
{"lat", "lon"} dict, a GERS id, or a free-text place name — but not
both (and not neither); either way returns {"error": "bad_request"}
naming the choice. A `where` given as an id/name adds a compact
"resolved": {"name", "id", "lat", "lon", "matched_by"} to the answer
(a separate key from the answer's own "where" section below); absent
for lat/lon or a {lat,lon} where. Returns:
\- where: reverse_geocode's answer for the point (address/divisions
  chain, or a "divisions_only" degrade).
\- surroundings: total places and the top few categories within a fixed
  500m radius, plus density_per_km2.
\- reach: reachable-area stats only for (minutes, mode) —
  {reachable_nodes, max_radius_m, area_km2}. Never includes the
  reachable-area polygon; this tool returns no geometry, ever.
\- notable: the nearest 2-3 named places, no category filter.

Each section is independent: if its underlying call fails or comes
back empty, that section is dropped and a short line explaining why is
added to "notes" instead — the call only fails outright if every
section failed, returning a structured {"error":
"upstream_unavailable", ...}.

minutes must be > 0 and <= 60; omit mode to use the stored
preferences mode, else walk.
Both, plus out-of-range coordinates, return {"error": "bad_request"}.
No confirm gate: the reach scan runs with the requested minutes/mode
as-is (it self-caps its graph extraction radius; no nearby street
graph just degrades the reach section to a note).

Input parameters:

- `lat`
- `lon`
- `minutes` (number)
- `mode`: Travel mode. Default: stored preference, else walk.
- `where`

### `places_along_route` (~543 tokens)

Places along a route

Places on the way from A to B: corridor search along the route.

Answers "find a coffee shop on my drive to the airport" — the route tool
plus find_places in one call. Builds the same street-graph shortest path
\`route` returns, then finds places whose nearest point on that path is
within max_detour_m (default 1000m, capped at 5000m; larger values
return a bad_request error rather than being silently clamped).

Each result row is a find_places row plus two numbers: detour_m, the
straight-line distance to the route doubled — an approximation of the
round trip off and back on, not a re-routed detour — and along_m, how
far along the route from the origin that place sits, so "roughly
halfway" is answerable. Results are ordered by along_m (route order,
reading as an itinerary) rather than by detour cost. When more than
limit places are on the way, the response is an even sample spanning the
whole route — never just the first limit, which would drop the far end
of the journey — and carries "truncated": true saying so. It also
carries {"route": {"distance_m", "duration_s", "mode"}} for the
underlying route. A composed itinerary also carries
verify_before_going when any stop is low-confidence or listed closed,
naming the 1–2 places most worth checking.

category and name narrow the search exactly as they do in find_places
(category matches Overture's taxonomy, e.g. 'coffee_shop'; name is a
substring match) — worth passing on a long route, since an unfiltered
corridor through a dense area can hold more places than the search
considers, in which case the response carries "truncated": true and a
note saying so.

Omit mode to use the stored preferences mode, else drive. Same cost model
and the same straight-line-distance caps as `route`, and the same
structured errors: route_too_long, no_graph_nearby, no_route,
unsupported_mode, and bad_request for non-finite/out-of-range
coordinates or an invalid max_detour_m.

Input parameters:

- `category`
- `from_lat` (number, required)
- `from_lon` (number, required)
- `limit` (integer)
- `max_detour_m` (number)
- `mode`: Travel mode. Default: stored preference, else drive.
- `name`
- `to_lat` (number, required)
- `to_lon` (number, required)

### `neighborhood_verdict` (~225 tokens)

Neighborhood verdict

Life-decision neighborhood verdict, not a data dump.

Accepts a point plus free-form life context (household, mobility,
priorities) and returns a ranked verdict: strengths, weak points, and
the one thing to verify in person. Empty context still answers a
generic walk-first daily-needs check and says what was assumed.
Optional radius_m / minutes / mode override what the context implies
(no car / walk-first -> walk, bike -> cycle, car -> drive; default
walk, 15 minutes). Does not call out to extra remote APIs.

Returns a structured {"error": ...} for bad coordinates, an unknown
mode, a radius past the mode cap, upstream failure, or a degraded
schema. Missing street graph degrades to straight-line times with a
note rather than failing the verdict.

Input parameters:

- `context` (string)
- `lat` (number, required)
- `lon` (number, required)
- `minutes`
- `mode`: Travel mode override. Default: inferred from context, else walk.
- `radius_m`

### `verify_claims` (~761 tokens)

Verify listing claims

Grade spatial listing claims ("8 min to the metro", "shops on the doorstep",
"green space nearby") against real routing and places data.

Free-text claim parsing needs an LLM — this tool takes already-decomposed
structured checks; the verify_listing_claims prompt teaches an agent how
to turn listing text into them. Each of claims (max 8; max 5 of kind
travel_time, since each costs a routed call) is one of:

\- {"kind": "travel_time", "to_category": str|None, "to_name": str|None,
  "mode": "walk"|"cycle"|"drive" (default walk), "claimed_minutes": number}
  Finds the nearest place matching to_category (an Overture taxonomy
  slug) and/or to_name (a substring match), then routes to it and
  compares the routed minutes against claimed_minutes.
\- {"kind": "count_nearby", "category": str|None, "name": str|None,
  "radius_m": number (default 500, capped at 2000), "claimed_at_least": int}
  Counts matching places within radius_m and compares against
  claimed_at_least.
\- {"kind": "distance", "to_category": str|None, "to_name": str|None,
  "claimed_max_m": number}
  Straight-line distance (haversine, not routed) to the nearest match,
  compared against claimed_max_m.

Every kind needs at least one of its category/name fields; giving
neither is a bad_request. A category is an Overture taxonomy slug,
matched exactly (including its taxonomy descendants), never as a
substring — "park" does not match a parking garage; a name is a
substring match.

Verdict per claim: "confirmed" when the measured number is within the
claimed number x1.15 (count_nearby: measured count >= claimed),
"stretched" within x1.5 (count_nearby: count >= half the claim, floor
1), otherwise "false". A claim asserting a place exists at all, when
none is found within the search bound, is "false" with a note —
absence is a verdict, not an error. A claim the measurement cannot
decide is "unverifiable" instead of "false": a travel_time claim whose
place is found but cannot be routed to (no street graph nearby, or…

Input parameters:

- `claims` (array, required)
- `lat` (number, required)
- `lon` (number, required)

### `optimize_route` (~792 tokens)

Best visiting order for stops

Best order to visit several stops: multi-stop route ordering (a small TSP).

Answers "I have these five errands, what order costs least" — stops is a
list of 2-10 points, each a {"lat": ..., "lon": ..., "name": ...
(optional)} dict, a GERS id, or a free-text name, mixed freely. The
answer is the cheapest visiting order over the real street graph, not a
straight-line guess. Solved exactly (Held-Karp over the routed cost
matrix), so it is the optimum, not a nearest-neighbour approximation.
Any stop given by id/name that failed to resolve returns an indexed
error (stops[i]: ...) with candidates on ambiguity — the whole call
fails, not silently drops that stop.

Returns {"order": [stop indices, in visiting order], "legs":
[{"from_idx", "to_idx", "distance_m", "duration_s"}, ...],
"total_distance_m", "total_duration_s", "mode", "roundtrip", "export"}
— indices refer to the input `stops` list, and there is no
polyline/geometry — for a single pair's numbers on their own, call
\`route`. export is the pocket handoff: a multi-stop Google/Apple Maps
directions URL (coordinates only — no Maps API), a GPX 1.1 document
with every stop as a waypoint, and a printable list that keeps any
names the caller passed. If a stop already carries confidence or
operating_status (from a prior place lookup), the response adds
verify_before_going naming the 1–2 weakest. Any stop given as an id or
name adds "resolved": [{"stop": i, "name", "id", "lat", "lon",
"matched_by"}, ...] for just those stops — plain {lat,lon} stops need
no echo and the key is absent when every stop was already coordinates.

start_index (default 0) is fixed as the first stop. roundtrip=true (the
default) returns to it; the closing leg is in "legs" but the start is not
repeated in "order". roundtrip=false is an open path that ends wherever
is cheapest. Omit mode to use the stored preferences mode, else drive. Same
cost model every routing tool uses; one-ways make the drive/cycle cost
matrix asymmetric and that is solved for…

Input parameters:

- `mode`: Travel mode. Default: stored preference, else drive.
- `roundtrip` (boolean)
- `start_index` (integer)
- `stops` (array, required)

### `preferences` (~271 tokens)

Persistent preferences

Travel defaults.

State "I bike everywhere, I have a dog" once. Routing tools use the
stored mode when you omit theirs; an explicit argument always wins.
pace and household are stored for later features and do not change
answers yet. lang (#410) is the stored result-language preference: the
name-lookup tools that accept their own `lang` use this one when
theirs is omitted, returning an Overture-tagged name variant (e.g.
"Munich" for "München" with lang="en") — a per-call `lang` always
wins. The same document is the placeroot://preferences resource.

Call with no arguments to read. Pass mode, pace, household tags, a
free-text note, or lang to merge those fields.
clear=true deletes the file and cannot be combined with other fields.
Nothing is sent off this machine.

Input parameters:

- `clear` (boolean)
- `household`
- `lang`: Result-language code (2-3 lowercase letters, e.g. "de"). Overture-tagged name variants only — never transliterated or invented. Default: stored preference, else the primary name.
- `mode`: Travel mode to store. Omit to leave it unchanged.
- `note`
- `pace`

### `warmup_city` (~194 tokens)

Get to know my city

Pre-cache a city.

Copies places and transportation tiles into the same local cache later
queries read. Does not build the routing graph (the first route still
pays that cost) and does not pre-cache buildings. The warmup call is
the slow one; later place searches over the area read locally.

radius_m defaults to 8000 (a city core) and is capped at 25 km so a
warmup cannot fan into a planet-sized tile fetch.

confirm=true after the user agreed to wait for a first-time tile
warmup (about 5–25 seconds). Pass it only after a needs_confirm reply
and they said yes. An already-cached city never needs it.
Omit confirm unless you just asked and they said yes.

Input parameters:

- `city`
- `confirm` (boolean)
- `lat`
- `lon`
- `radius_m` (number)

### `data_version` (~139 tokens)

Data version

Which Overture Maps release backs the answers from every other tool.

Reports the active release string, its date, and whether it came from
live S3 discovery, an operator env override, or the pinned fallback
baked into this build. Resolved once at process start and cached for
the process lifetime — this tool just reports that cached value, it
doesn't re-check upstream, so it's small and has no upstream DB
dependency.

The body is resources.data_version_payload(), shared verbatim with the
placeroot://data-version MCP resource so the two surfaces cannot drift
(issue #195); tests/test_resources.py asserts they stay equal.

## Diagnostics

Captured diagnostic sections: Provenance, Install scripts, Dependencies. The full working is on the page: https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2#diagnostics

## Score history

- 2026-09-20: 64
- 2026-09-19: 63
- 2026-09-18: 63
- 2026-09-17: 62
- 2026-09-16: 62
- 2026-09-15: 61
- 2026-09-14: 64
- 2026-09-13: 64
- 2026-09-12: 79
- 2026-09-11: 63
- 2026-09-10: 63
- 2026-09-09: 62
- 2026-09-08: 62
- 2026-09-07: 76
- 2026-09-06: 76
- 2026-09-05: 75
- 2026-09-04: 75
- 2026-09-03: 60
- 2026-09-02: 59
- 2026-09-01: 74
- 2026-08-31: 58
- 2026-08-30: 73
- 2026-08-29: 72
- 2026-08-28: 72
- 2026-08-27: 56
- 2026-08-26: 56
- 2026-08-25: 68
- 2026-08-24: 53
- 2026-08-23: 52
- 2026-08-22: 68

## Common questions

### What is the PlaceRoot MCP server?

PlaceRoot is an MCP server listed in the public MCP registry as io.github.chuofringer/placeroot. Grounds AI agents in Overture Maps open data, compact answers, no API key. This page covers its PyPI package (placeroot).

### Is the PlaceRoot MCP server safe to use?

PlaceRoot scores 64 out of 100 on VerifyMCP. We found no known CVEs affecting it as of 20 September 2026. That is a record of what we were able to check automatically, not an endorsement. The category breakdown on this page shows every signal behind the number, including the ones we could not confirm.

### What tools does the PlaceRoot MCP server expose?

PlaceRoot exposes 42 tools: find_places, summarize_area, place_details, within_distance, distance_matrix, and 37 more. Their descriptions and schemas cost roughly 21,138 tokens of context every time the server is loaded.

### Is the PlaceRoot MCP server still maintained?

PlaceRoot is still listed as active in the MCP registry. We last reached this channel on 20 September 2026. Those dates come from our own scans of the registry and the channel itself, not from anything the publisher announced.

## Links

- PyPI project: https://pypi.org/project/placeroot/
- Socket report: https://socket.dev/pypi/package/placeroot
- Repository: https://github.com/chuofringer/placeroot
- Website: https://placeroot.dev/
- Changelog RSS feed: https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2.xml
- Changelog JSON feed: https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2.json
- HTML version of this page: https://verifymcp.io/servers/chuofringer-placeroot/placeroot-2
