Skip to main content
Glama

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.


Related MCP server: @nor-data/nabolag-mcp

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

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):

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):

{
  "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

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. 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.

Available Tools

7 tools
catchment_poisA

Points of interest inside a site's catchment, grouped by category.

Units: lat/lon decimal degrees; minutes minutes. categories is enum-constrained (no free-text queries). Example: catchment_pois(lat=3.15, lon=101.71, minutes=10, mode="walk", categories=["cafe","retail"])

Inputs: lat (required), lon (required), mode (required), minutes (required), +1 more. Returns: POIs inside a site's catchment, grouped by category.

compare_sitesA

Rank candidate sites by reach score (server-side, deterministic).

Ranking is computed here, not by the client model. Tie-break chain: score desc, POI density desc, category diversity desc, name asc. Units: lat/lon decimal degrees; minutes minutes. Example: compare_sites(sites=[{"name":"KLCC","lat":3.16,"lon":101.71}, {"name":"Bukit Bintang","lat":3.15,"lon":101.72}], minutes=10, mode="walk")

Inputs: mode (required), sites (required), minutes (required), weights, +1 more. Returns: Ranked result of comparing candidate sites.

geocodeA

Forward geocode a place name to a WGS84 coordinate (decimal degrees).

Units: latitude/longitude in decimal degrees. Example: geocode(query="KLCC") -> GeoPoint(lat=3.1579, lon=101.7116)

Inputs: query (required). Returns: A WGS84 coordinate.

isochroneA

Reachable-area polygon within a travel-time budget.

Units: lat/lon in decimal degrees; minutes in minutes; polygon ring as [lon, lat] pairs; area_m2 in square metres. Example: isochrone(lat=3.15, lon=101.71, mode="walk", minutes=10)

Inputs: lat (required), lon (required), mode (required), minutes (required). Returns: Reachable area polygon within a time budget. ``polygon`` is a list of ``[lon, l…

pingA

Health-check tool.

Returns {"status": "ok"} so any MCP client can confirm reachability.

Example: ping() -> {"status": "ok"}

Inputs: no named parameters. Returns: object

reverse_geocodeA

Reverse geocode a coordinate to a human-readable address.

Units: lat/lon in decimal degrees; returns a display-name string. Example: reverse_geocode(lat=3.15, lon=101.71) -> "Bukit Bintang, ..."

Inputs: lat (required), lon (required). Returns: result

site_reach_scoreA

Composite reach score in [0, 1] for a single OOH site.

Combines gravity-weighted POI density, junction density, and category diversity (public urban-analytics methodology). Returns the score plus an auditable breakdown. Units: lat/lon decimal degrees; minutes minutes; score unitless [0,1]. Example: site_reach_score(lat=3.15, lon=101.71, minutes=10, mode="walk")

Inputs: lat (required), lon (required), mode (required), minutes (required), +2 more. Returns: Composite reach score for a single site. ``score`` is in ``[0, 1]``; ``breakdow…

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    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

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers