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

A4/5.0

Scored across 7 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness4/5

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.

Maintenance

ActivityStale
ResponsivenessNo issues