Skip to main content
Glama
Rusty0508
by Rusty0508
README.md
# 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

A4/5.0

Scored across 7 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues