Open Reach
# Open Reach
**An MCP server for out-of-home (OOH) site-reachability tooling.**
Open Reach exposes geospatial *reachability* and *catchment-scoring* tools for
billboard / ad-site evaluation — isochrone catchments, POI-density scoring, and
multi-site comparison — to any MCP client (Claude Desktop, Cursor, etc.), built
entirely on open geospatial data and public urban-analytics methodology.
> The differentiator is server-side reach scoring and multi-site ranking
> (`site_reach_score`, `compare_sites`). There are several public OSM-wrapper MCP
> servers already; none of them do catchment *scoring*. That gap — plus honest,
> public methodology — is the point of this project.
---
## The hook (demo)
```
You (in Claude Desktop):
"I'm scouting 3 candidate billboard sites near KLCC, Bukit Bintang, and
Mont Kiara. Which has the best pedestrian catchment and highest POI
density within a 10-minute walk?"
Claude:
→ geocode("KLCC"), geocode("Bukit Bintang"), geocode("Mont Kiara")
→ isochrone(..., mode="walk", minutes=10) [x3]
→ catchment_pois(..., categories=["cafe","retail","transit"]) [x3]
→ site_reach_score(...) [x3]
→ compare_sites(sites=[...], minutes=10, mode="walk")
← ranked: 1. Bukit Bintang 2. KLCC 3. Mont Kiara (with per-site breakdown)
```
The ranking arithmetic runs **server-side** in `compare_sites` — the model's job
is orchestration and narration, not the math.
---
## How it works
```
┌─────────────────────┐ MCP (JSON-RPC over stdio)
│ MCP Client │ ──────────────────────────────┐
│ (Claude Desktop / │ │
│ Cursor / Code) │ ◄── tool schemas advertised │
└─────────────────────┘ ▼
┌────────────────────────────────────┐
│ Open Reach MCP Server │
│ (FastMCP process) │
│ │
│ @mcp.tool: geocode │
│ @mcp.tool: reverse_geocode │
│ @mcp.tool: isochrone │
│ @mcp.tool: catchment_pois │
│ @mcp.tool: site_reach_score ★ │
│ @mcp.tool: compare_sites ★ │
│ │
│ TTL cache + per-host rate limiting │
└──────────────────┬───────────────────┘
│ https GET/POST
┌──────────────────────────────────┼───────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌──────────────────────┐ ┌───────────────────┐
│ OSM Nominatim │ │ Overpass API │ │ OSRM │
│ (geocoding) │ │ (POIs by category) │ │ (isochrones) │
└───────────────────┘ └──────────────────────┘ └───────────────────┘
```
Each scoring tool chains isochrone → POIs/junctions → a **pure, unit-tested**
arithmetic module, then returns a typed result plus an auditable breakdown.
---
## Tools
| Tool | Description | Units |
|---|---|---|
| `geocode(query)` | Forward geocode a place name. | lat/lon decimal degrees |
| `reverse_geocode(lat, lon)` | Coordinate → address string. | decimal degrees → string |
| `isochrone(lat, lon, mode, minutes)` | Reachable-area polygon within a time budget. | minutes; polygon `[lon,lat]`; area m² |
| `catchment_pois(lat, lon, minutes, mode, categories)` | POIs in the catchment, grouped by enum category. | counts |
| `site_reach_score(lat, lon, minutes, mode, weights?)` | Composite reach score `[0,1]` + breakdown. ★ | unitless `[0,1]` |
| `compare_sites(sites, minutes, mode, weights?)` | Deterministic multi-site ranking. ★ | ranks (1 = best) |
`mode` ∈ `{walk, drive, transit}`. `categories` are **enum-constrained** — callers
never supply raw Overpass QL; all queries are built server-side.
---
## Quick start
```bash
git clone https://github.com/kasturi/open-reach-mcp.git
cd open-reach-mcp
python -m venv .venv && .venv\Scripts\activate # Windows
# source .venv/bin/activate # macOS/Linux
pip install -e ".[dev]"
```
Run standalone (stdio):
```bash
open-reach-mcp # or: python -m open_reach_mcp
```
### Claude Desktop config
Add to `claude_desktop_config.json` (macOS:
`~/Library/Application Support/Claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"open-reach": {
"command": "C:\\path\\to\\open-reach-mcp\\.venv\\Scripts\\open-reach-mcp.exe",
"args": []
}
}
}
```
> Set `USER_AGENT` (see `.env.example`) to a descriptive value — Nominatim usage
> policy requires it.
### Tests
```bash
pytest --cov=open_reach_mcp --cov-report=term-missing
```
---
## Methodology & IP boundary (important)
The reach-scoring formula in `site_reach_score` is derived **only from public
urban-analytics literature**, not from any proprietary methodology or real
campaign/device data:
- **Gravity-model catchment accessibility** (Hansen, 1959; classic spatial
interaction) — each POI contributes `exp(-d / D)` where `d` is its distance
from the site and `D` a pedestrian decay constant.
- **Walkability-style density indices** — POI density and road-junction density
per km², normalized against public reference benchmarks to `[0, 1]`.
- **Category-mix diversity** via normalized Shannon entropy.
This project stands for the *engineering pattern* (geospatial proxy scoring under
rate-limited free APIs, packaged as an MCP server). It is **not** a claim of
parity with any production reach/audience system, and it uses no proprietary
formulas or real client data.
---
## Upstream dependencies & limits
All upstream APIs are free-tier and shared/public, so they are rate-limited and
occasionally fragile. Open Reach mitigates this with:
- **TTL cache** (`CACHE_TTL_SECONDS`, default 24h) + **per-host rate limiting**
(`RATE_LIMIT_MIN_INTERVAL_SECONDS`, default 1s — Nominatim's policy ceiling).
- A compliant **`User-Agent`** header (configurable; required by Nominatim).
- **`transit` mode falls back to the OSRM `foot` profile** — OSRM has no transit
router. For real transit isochrones, self-host a transit router (e.g.
RAPTOR/OTP) and point `OSRM_BASE_URL` at it. This is a known limitation, stated
openly rather than hidden.
- For production throughput, **self-host** Nominatim / Overpass / OSRM (Docker
images exist for all three) and set the `*_BASE_URL` env vars. Turning upstream
fragility into a documented self-host path is part of the point.
---
## Configuration
All settings are environment-driven (see `.env.example`):
| Variable | Default | Purpose |
|---|---|---|
| `NOMINATIM_BASE_URL` | `https://nominatim.openstreetmap.org` | Geocoding upstream |
| `OVERPASS_BASE_URL` | `https://overpass-api.de` | POI upstream |
| `OSRM_BASE_URL` | `https://router.project-osrm.org` | Routing upstream |
| `USER_AGENT` | `open-reach-mcp/0.1.0 (...)` | Required by Nominatim policy |
| `CACHE_MAXSIZE` / `CACHE_TTL_SECONDS` | `2048` / `86400` | TTL cache sizing |
| `RATE_LIMIT_MIN_INTERVAL_SECONDS` | `1.0` | Per-host request spacing |
| `HTTP_TIMEOUT_SECONDS` | `15.0` | Upstream call timeout |
---
## Publishing
v1 ships **stdio** transport. Publishing steps (run manually after build):
1. **PyPI**: `python -m build && twine upload dist/*`.
2. **Official MCP registry**: submit `server.json` (verified GitHub ownership) at
[registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io).
PyPI alone no longer supports a "published to the registry" claim; the official
registry (launched Sept 2025) requires the manifest + verified ownership.
> SSE-only transports are deprecated since MCP spec 2025-03-26. A **Streamable
> HTTP** transport is the planned v2 stretch (no SSE).
---
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 7 tools
Each tool serves a distinct purpose: health check, forward/reverse geocoding, isochrone generation, POI query, and reach scoring/comparison. No two tools overlap in function, minimizing agent confusion.
Most tools follow a verb_noun or noun_phrase pattern (geocode, reverse_geocode, compare_sites). However, 'isochrone' and 'catchment_pois' are noun-oriented rather than verb-first, introducing slight inconsistency but still clear.
With 7 tools, the server covers a focused domain (location reachability analysis) without bloat. Each tool feels necessary, and the count is well within the 3–15 sweet spot.
The set provides a complete workflow: geocode, reverse geocode, compute isochrone, query POIs, score a site, and compare multiple sites. A minor gap is the lack of a tool to fetch POI details or handle transit-specific parameters, but core needs are met.