overpass-mcp
# overpass-mcp
An MCP (Model Context Protocol) server that gives an AI agent typed, structured
access to OpenStreetMap data through two public, key-free APIs:
[Overpass](https://overpass-api.de/) (feature queries) and
[Nominatim](https://nominatim.openstreetmap.org/) (geocoding). No API keys, no
paid tier — just the public OSM infrastructure, used the way its operators ask
it to be used.
## What this is
Seven tools, each returning a Pydantic-validated, JSON-serializable result:
- geocoding a place name to coordinates and a bounding box
- finding tagged elements (`amenity=cafe`, `shop=bakery`, ...) near a point or
inside a bounding box
- fetching a single OSM element by type and id
- counting matches cheaply, without pulling full geometry
- listing common OSM tag keys/values as a static, offline reference
- running a raw Overpass QL query as an escape hatch
Every tool returns either a valid result or a structured error object — never
an exception. See [Design notes](#design-notes) below for why that distinction
matters for an MCP server specifically.
## Installation
```bash
git clone https://github.com/Rusty0508/overpass-mcp.git
cd overpass-mcp
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
Requires Python 3.11+.
## Configuration for an MCP client
Add the server to your MCP client's config (for Claude Desktop, this is
`claude_desktop_config.json`; for the Claude Code CLI, `.mcp.json` or via
`claude mcp add`):
```json
{
"mcpServers": {
"overpass": {
"command": "/absolute/path/to/overpass-mcp/.venv/bin/overpass-mcp"
}
}
}
```
The `overpass-mcp` console script is installed by `pip install -e .` (see
`[project.scripts]` in `pyproject.toml`) and talks over stdio, which is what
most MCP clients expect by default. Alternatively, run it directly:
```bash
python -m overpass_mcp.server
```
No environment variables or API keys are needed — both upstream APIs are
public and unauthenticated.
## Tools
| Tool | Parameters | Returns |
|---|---|---|
| `geocode_place` | `query: str`, `limit: int = 1` (1-10) | List of matches: name, coordinates, bounding box, OSM type/id |
| `find_places_nearby` | `lat: float`, `lon: float`, `radius_m: int` (1-50000), `tag_key: str`, `tag_value: str \| None`, `limit: int = 50` (1-200) | List of `Place` objects + count |
| `find_places_in_area` | `south, west, north, east: float`, `tag_key: str`, `tag_value: str \| None`, `limit: int = 50` | List of `Place` objects + count |
| `get_element` | `element_type: "node" \| "way" \| "relation"`, `element_id: int` | A single `Place` object |
| `count_places` | `south, west, north, east: float`, `tag_key: str`, `tag_value: str \| None` | `{total, nodes, ways, relations}` |
| `list_common_tags` | none | Static dict of tag key -> popular values (no network call) |
| `raw_overpass_query` | `ql: str` (max 8000 chars) | Raw parsed Overpass JSON response |
A `Place` is `{osm_type, osm_id, name, coordinates: {lat, lon} | null, tags}`.
Every tool response is wrapped as either `{"ok": true, "data": {...}}` or
`{"ok": false, "error": {"code": ..., "message": ..., "hint": ...}}`.
## Example calls
Geocode a place:
```json
{"tool": "geocode_place", "arguments": {"query": "Alexanderplatz, Berlin"}}
```
```json
{"ok": true, "data": {"results": [{"name": "Alexanderplatz, Mitte, Berlin, Germany",
"coordinates": {"lat": 52.521, "lon": 13.413},
"bounding_box": {"south": 52.520, "west": 13.410, "north": 52.522, "east": 13.416},
"osm_type": "way", "osm_id": 123456}]}}
```
Find cafes within 500m of a point:
```json
{"tool": "find_places_nearby",
"arguments": {"lat": 52.521, "lon": 13.413, "radius_m": 500, "tag_key": "amenity", "tag_value": "cafe"}}
```
Count fuel stations in a bounding box without fetching their geometry:
```json
{"tool": "count_places",
"arguments": {"south": 52.3, "west": 13.0, "north": 52.7, "east": 13.7, "tag_key": "amenity", "tag_value": "fuel"}}
```
A failure looks like this (never a stack trace, never a raised exception):
```json
{"ok": false, "error": {"code": "TIMEOUT",
"message": "timeout calling https://overpass-api.de/api/interpreter",
"hint": "upstream did not respond in time; retry, or reduce the search radius/area"}}
```
## Design notes
### Why errors are returned, not raised
Every tool in this server catches its own failures and returns a structured
`{"ok": false, "error": {"code", "message", "hint"}}` object instead of letting
an exception propagate out of the tool call. This is a deliberate choice, not
an oversight of Python idiom.
An MCP tool call happens inside an agent's reasoning loop. If the tool raises,
the exception surfaces as a protocol-level failure the agent cannot reason
about the way it can reason about data — depending on the client, it can look
like the tool doesn't exist, or it can terminate the turn outright. Either way,
the agent loses the chance to notice *what kind* of failure happened and
decide what to do next: retry a timeout, back off on a 429, or tell the user a
bounding box was invalid and to please review it. A structured error is just
another shape of successful tool output — the agent reads `error.code`, decides
on a strategy, and keeps going. The distinction that matters here is not
"exception vs. return value" as a Python style preference; it is "does the
protocol layer see a broken tool, or does the agent see actionable
information." An MCP server is a service boundary, and prompted agents behave
better with predictable failure data than with the interruption of an
exception.
### Idempotency-aware retry
`client._request_with_retry` retries on timeout and 5xx responses, with
exponential backoff, for both the Overpass POST call and the Nominatim GET
call. The common heuristic — "retry GET, never retry POST" — uses the HTTP
method as a proxy for whether a retry is safe. That heuristic is the right
default when the method is unknown, but here the actual property that matters
is checked directly: neither upstream API has a write endpoint at all, and
both calls used by this server are pure reads. Overpass happens to use POST
only because a QL query body doesn't fit comfortably into a query string —
semantically it is a `GET`. Retrying is therefore safe for both calls: repeating
the same request cannot create a duplicate side effect, because there is no
side effect to duplicate. A 429 is handled separately from timeouts/5xx: if the
response carries a `Retry-After` header, the retry waits exactly that long
instead of using its own backoff schedule, because the upstream server is
telling us precisely how long to wait.
### Respecting public infrastructure
Both APIs are free, key-free, and run by volunteers/small teams on
donated infrastructure — nothing about them requires payment, but that also
means nothing stops a careless client from taking them down for everyone else.
This server takes their published usage policies as hard constraints, not
suggestions:
- Nominatim's documented limit of one request per second is enforced in code
(`asyncio.Lock` + a monotonic timestamp), not left to the caller's
discipline — the lock ensures it holds even under concurrent tool calls
from the same process.
- Every request sends a descriptive `User-Agent` identifying the project,
because Nominatim blocks generic/default user agents outright.
- Every request has an explicit connect/read/write/pool timeout — nothing
waits forever, and the server does not hold a connection open speculatively.
- `raw_overpass_query` has a hard length cap (8000 characters) so a single
agent-generated query cannot balloon into something that hurts a shared
public endpoint.
### `out center` for way/relation
Overpass elements come in three kinds — `node`, `way`, `relation` — and only
`node` carries coordinates directly. A `way` is a sequence of node references;
a `relation` is a set of member references; neither has a `lat`/`lon` of its
own. Every query built by this server appends `out center;`, which asks
Overpass to compute and attach a centroid to `way`/`relation` elements. Forgetting
this is a common, easy-to-miss bug: the query still succeeds, still returns
elements, and roughly half the results (every non-`node`) simply come back
with no usable position — a silent hole in the data rather than a visible
error. `element_to_place` reads `lat`/`lon` directly for nodes and falls back
to `center.lat`/`center.lon` for ways/relations, and its `coordinates` field
is only `None` in the rare case where Overpass itself could not resolve a
center.
## Testing
```bash
source .venv/bin/activate
python -m pytest tests/ -v
ruff check .
```
All network access in tests is mocked with [respx](https://lundberg.github.io/respx/)
at the `httpx` transport layer — the test suite never contacts
overpass-api.de or nominatim.openstreetmap.org.
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 7 tools
Each tool targets a distinct operation: geocoding, spatial search by radius or bbox, single-element fetch, counting, raw querying, and tag reference. The two search tools are clearly differentiated by spatial constraint, and the counting tool explicitly avoids returning geometries.
Most tool names follow a verb_noun pattern (e.g., geocode_place, find_places_nearby, get_element), but raw_overpass_query deviates from this pattern by using an adjective_noun construction. Overall, the naming is mostly consistent and easy to predict.
With seven tools, the set is well-scoped for an Overpass API server: it covers common high-level operations plus a raw query escape hatch without unnecessary redundancy. This is within the ideal 3-15 tool range.
The tools cover the core workflow of geocoding, spatial querying, fetching specific elements, and counting. The raw_overpass_query tool ensures any unexpressed Overpass query is still possible, eliminating dead ends and making the surface functionally complete.