Skip to content
Conduit

mcp server

geolink-mcp

MCP server for GeoLink — geocoding, reverse geocoding, place search, directions, distance matrix, road-time nearest-branch ranking, and grid-based area coverage sweeps for AI agents.

kariemSeiam/geolink-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • Node.js 20 or newer
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "geolink-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "geolink-mcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 26, 2026

GeoLink MCP

🧭 GeoLink MCP

Turn any AI agent into a location expert — geocoding, search, routing, and full-area sweeps over the Model Context Protocol

CI CodeQL Remote MCP License: MIT Node TypeScript PRs Welcome

Quickstart · Tools · Why · Architecture · Prompts · Self-host · FAQ


⚡ 60-second quickstart

You need a free GeoLink API key — no credit card, sign-up takes under a minute. Then pick one of two ways in.

Hosted — paste a URL, nothing to install

A live instance runs at https://geolink.kariem.dev/mcp. Your client discovers its OAuth metadata, opens a consent page, and you paste your own GeoLink key there — it is exchanged for a token bound to that key, so no shared credential ever exists.

claude mcp add --transport http geolink https://geolink.kariem.dev/mcp

Settings → Connectors → Add custom connector → paste https://geolink.kariem.dev/mcp.

{ "mcpServers": { "geolink": { "url": "https://geolink.kariem.dev/mcp" } } }

Local — stdio, from source

Needs Node.js ≥ 18. Your key stays in your own environment.

git clone https://github.com/kariemSeiam/geolink-mcp && cd geolink-mcp
npm ci && npm run build
claude mcp add geolink -e GEOLINK_API_KEY=your_api_key_here -- node "$PWD/dist/index.js"

Any other stdio client takes the same shape: command node, args ["/absolute/path/to/geolink-mcp/dist/index.js"], env GEOLINK_API_KEY.

[!NOTE] geolink-mcp is not on the npm registry yet, so npx -y geolink-mcp will not resolve. Use one of the two paths above.

Then ask something that needs a map

"What's the fastest way from Tahrir Square to Cairo Airport right now?" "Find every pharmacy within 3km of this address and rank them by real drive time." "Sweep this whole city for coffee shops — dry-run it first so I know the cost."

No map SDK to wire up, no dashboard to learn.


🤔 Why this server exists

Most geocoding MCP servers are thin 1:1 wrappers: one tool per endpoint, raw JSON dumped straight into context. That works until an agent asks for something an endpoint doesn't answer directly — "which of these five branches is actually closest by road?" or "list every pharmacy in this district, not just the ten a single search call returns."

GeoLink MCP is built the other way around: agent-shaped answers first, endpoints second.

Instead of…You get…
A search that quietly stops at one pagegeolink_search_places pages to the depth you ask for and returns source_exhausted, so "no more results" is a fact you're told, not a guess
One search call returning one page from one pointgeolink_sweep_area — tiles a whole region with a query grid, merges, de-duplicates, and groups results by district, with a dry_run that tells you the exact API cost before you spend it
Straight-line "nearest" that lies in cities with rivers, one-ways, and bridgesgeolink_find_nearest — ranks candidates by real road travel time, not centimeters on a map
A wall of raw coordinate arrays for every routegeolink_get_directions with route_detail — summary by default, full polyline or sampled waypoints only when you ask, because geometry is 90% of the token cost and 10% of the value
Silent truncation or a stack traceA structured error envelope — Error (<kind>): <what happened> + Next step: <exactly what to change> — so agents self-correct instead of retrying blind

Every tool is read-only, every response validates against an explicit outputSchema, and every list respects a hard token ceiling with an honest truncation message instead of quietly dropping data.


🛠 Tools

All seven tools accept language / country / response_format (markdown or json) and return machine-readable structuredContent. Any parameter documented as a location accepts either coordinates ("lat,lng" or {lat, lng}) or a place name — names are geocoded automatically and cached in-process for 10 minutes.

ToolWhat it answersUpstream calls
🔎 geolink_geocode"Where is this address?" → one place, structured address_parts, viewport bounds1
📍 geolink_reverse_geocode"What's at these coordinates?" → address with address_parts1
🗺️ geolink_search_places"Find category/brand near here" → as deep as limit asks, sorted by distanceceil(limit/20) (+1 if near is a name)
🛣️ geolink_get_directions"How do I get from A to B?" → alternatives, geometry opt-in1 (+1 per named endpoint)
🧮 geolink_distance_matrix"Travel time between these N and those M?" → grid + nearest-per-origin1 (+1 per named location)
🎯 geolink_find_nearest"Which of these is really closest by road?" → ranked by real duration1 matrix (+1 search in discovery mode)
🕸️ geolink_sweep_area"Find everything of this kind in this region" → grid-tiled, de-duplicated, dry_run firstgrid points × ceil(results_per_point/20)

GeoLink's directions endpoint returns several route alternatives, each with a full waypoint path — hundreds of coordinate pairs. Dumping that straight into a model's context is mostly wasted tokens, so route_detail controls exactly how much geometry comes back:

route_detailReturnsUse for
summary (default)distance, duration, endpoints, bounds, waypoint_countETAs, comparisons, planning
polylinesummary + Google-encoded polyline (precision 5)drawing on a map — ~10× smaller than raw points
waypointssummary + [lat,lng][], evenly sampled to max_waypointswhen you genuinely need the coordinates

It also returns straight_line_km so an agent can sanity-check detour ratios, and silently repairs bounding boxes whose NE/SW corners arrive swapped upstream.

# Mode 1 — you already know the candidates
origin = "customer address" or "30.05,31.23"
candidates = ["Branch A", "30.10,31.30", ...]

# Mode 2 — let it discover candidates near the origin
search_query = "pharmacy"
candidate_limit = 10

rank_by = "duration" | "distance"

Mode 2 searches near the origin, keeps the candidate_limit closest by straight line as a pre-filter, then routes all of them in a single matrix call and re-ranks by real travel time. Results flag is_geolink_nearest when the winner also matches GeoLink's own nearest_destination_index — a free sanity check.

A single search returns one page from one center point. To find everything of a kind across a region, sweep tiles the area with a grid and merges:

  1. Resolve the area to bounds — {place: "..."} via geocode viewport, {center, radius_km}, or raw {bounds} — optionally padded by padding_km.
  2. Tile it with a square grid, one query point per grid_spacing_km × grid_spacing_km cell (longitude corrected for latitude; circular areas drop the corner points).
  3. Search once per grid point with bounded concurrency — individual point failures are tolerated, auth/quota errors abort the whole sweep immediately.
  4. Clip results to the area boundary, de-duplicate (same normalized name within dedupe_meters, script-aware — tashkeel stripped, alef/ta-marbuta/alef-maqsura folded for Arabic), and group by governorate and district.
  5. Shape the output — page with limit/offset, trim fields with fields, or emit a ready-to-render GeoJSON FeatureCollection.

Always run dry_run: true first. It returns the exact grid size and call count without touching the search endpoint — no surprise bills, no wasted quota. If the grid exceeds GEOLINK_SWEEP_MAX_POINTS, the error message tells you the smallest grid_spacing_km that fits.

Spacing cheatsheet: dense urban categories (pharmacies, cafés) → 2–3 km. Sparse categories or rural areas → 5–7 km. Clients that pass a progressToken get live notifications/progress as each grid point completes.


🧠 How it thinks

%%{init: {"flowchart": {"curve": "monotoneY"}, "themeVariables": {"fontSize": "15px"}} }%%
flowchart TB
    Client(["MCP Client\nClaude · Cursor · any agent runtime"])
    Gateway["protocol gateway"]
    Norm["normalize + cache\none envelope in, one shape out\n10-min geocode cache"]
    API[("GeoLink API\ngeolink-eg.com")]

    Client -- "stdio / streamable-http" --> Gateway

    subgraph Tools["seven tools"]
        direction LR
        Geo["geocode\nreverse_geocode"]
        Search["search_places"]
        Dir["get_directions"]
        Matrix["distance_matrix"]
        Nearest["find_nearest\n(composite)"]:::composite
        Sweep["sweep_area\n(composite)"]:::composite
    end

    Gateway --> Geo & Search & Dir & Matrix & Nearest & Sweep
    Geo & Search & Dir & Matrix & Nearest & Sweep --> Norm
    Norm --> API

    classDef composite fill:#6E56CF,stroke:#4c3a9e,color:#fff,font-weight:bold

Composite tools (find_nearest, sweep_area) fan out into several of the calls above — one matrix, several searches, several sweeps of the grid — before they ever reach normalize + cache.

Design principles, not slogans:

  • One response envelope in, one out. GeoLink's {success, data} / {success, error} shape is normalized exactly once, in the client. Tools never see raw upstream payloads, and every field is defaulted — outputSchema validation can't fail on a sparse response.
  • Token budget is a first-class constraint, not an afterthought. Route geometry is opt-in, matrix grids can collapse to nearest-only, sweep results page and field-trim, and every list response respects a 25,000-character ceiling with an explicit truncation message instead of silent data loss.
  • No hardcoded region. GeoLink's own coverage leads in Egypt today, but language / country are just optional bias parameters — everything works with coordinates or place names anywhere GeoLink resolves them, in any language it supports.
  • No fabricated data, ever. Bounds, districts, and addresses always come live from GeoLink's geocoder. Nothing in this server is a static local list pretending to be current.
  • Errors that teach. Every failure returns in-band as Error (<kind>): <what happened> — Next step: <the exact parameter change that fixes it>. An agent that hits a matrix-too-big error learns the batch size it needs, not just that something broke.
  • Sessions that clean up after themselves. HTTP mode keeps one MCP session per client, as the protocol expects, and reaps any session left idle for 30 minutes — a client that disappears without saying goodbye can't leak a session for the life of the process. stdio mode never writes anything but protocol frames to stdout.

💬 Prompts

Five ready-made prompt templates drive multi-step workflows without you having to script the tool sequencing yourself:

PromptArgumentsDrives
geolink_coverage_reportcategory, areadry-run → tune spacing → sweep → per-district summary with gap analysis
geolink_nearest_branchcustomer_location, branchesfind_nearest + recommendation, flags when straight-line and road-time disagree
geolink_route_brieforigin, destinationdirections with polyline + detour-ratio sanity check
geolink_coverage_auditcategory, areasweep, then test the result for saturation, overlap and edge loss before reporting a number
geolink_service_gapservice, demand_proxy, areatwo comparable sweeps → demand-versus-supply ranking → drive time to the nearest existing one

📚 Resources

URIContents
geolink://methodThe seven gates from a map question to a defensible answer, in the order they have to run
geolink://playbookWhich tool answers which question, and the distinction that causes most wrong answers: depth reads one center deeper, a sweep reads new ground
geolink://playbook/coverageThe three ways area coverage silently fails — saturated cells, edges outside the geocoded viewport, spacing wider than each search's reach — and the test for each
geolink://playbook/tripwiresThe ten ways a map answer comes back confidently wrong, each with its check
geolink://playbook/recipesCompositions across several tools: reachable-area approximation, territory assignment, underserved-ground analysis, on-the-way search, address-confidence checks
geolink://playbook/costCost formulas, measured latency, and the planning rules that follow
geolink://scaleMeasured call counts, latency and response variance against the live upstream
geolink://capabilitiesEndpoints, active defaults, cost formulas, the two guard rails that can refuse a request, and every error kind
geolink://egypt/governoratesThe 27 governorates of Egypt in English and Arabic, ready to use as a sweep area center

The playbook travels over the protocol rather than as a file, so it reaches any client — an IDE agent, a chat model, a harness with no filesystem — without anything to install.


🔧 Configuration

VariableDefaultNotes
GEOLINK_API_KEY—Required. Free tier, no card. Passed to GeoLink as the key query parameter — never logged, never echoed in output.
GEOLINK_BASE_URLhttps://www.geolink-eg.comOverride for staging or the bundled mock server.
GEOLINK_DEFAULT_LANGUAGEenAny tool call can override with language.
GEOLINK_DEFAULT_COUNTRY(none)Any tool call can override with country; unset means no bias.
GEOLINK_TIMEOUT_MS30000Per upstream request.
GEOLINK_MAX_MATRIX_CELLS100origins × destinations cap for matrix tools.
GEOLINK_SWEEP_MAX_POINTS200Max grid points (= max API calls) per sweep.
GEOLINK_SWEEP_CONCURRENCY4Parallel requests during a sweep.
TRANSPORTstdiostdio or http.
HOST / PORT127.0.0.1 / 3000HTTP transport only.

🖥 Self-hosting

git clone https://github.com/kariemSeiam/geolink-mcp
cd geolink-mcp
npm install && npm run build
GEOLINK_API_KEY=... node dist/index.js
GEOLINK_API_KEY=... TRANSPORT=http PORT=3000 node dist/index.js
# → POST http://127.0.0.1:3000/mcp   (stateless JSON; GET /healthz for liveness)

Binds to 127.0.0.1 by default. Set HOST=0.0.0.0 only behind a reverse proxy that handles auth and Origin validation.

docker compose up -d --build
curl http://127.0.0.1:3010/healthz

docker-compose.yml builds the image, sets TRANSPORT=http, and binds 127.0.0.1:3010 with a healthcheck baked in. Set GEOLINK_API_KEY in a local .env (see .env.example) before starting.

🚨 Errors

Every failure returns in-band (isError: true) as Error (<kind>): <what happened> + Next step: <what to do about it>.

auth · quota · not_found · bad_request · timeout · network · upstream

Guard-rail errors — a matrix over its cell cap, a sweep grid over its point cap — always include the exact parameter change that would succeed, so an agent can retry correctly on the first try.

🧪 Development

npm install
npm run build          # tsc → dist/
npm test               # unit tests: grid math, polyline (Google reference vector), dedup, normalizers
npm run test:e2e       # real MCP client ↔ server ↔ mock GeoLink, 29 checks, zero network needed
npm run inspect        # MCP Inspector against dist/index.js
bash scripts/smoke.sh  # HTTP transport, auth-failure path, startup validation, CLI flags

scripts/mock-geolink.mjs mirrors the documented response shapes — including deliberately swapped bounds and an out-of-area result — so the full test suite runs offline, deterministically, in CI.

🧭 The method

The tool list tells a client what exists. It does not tell it that a large limit reads one center more deeply while a sweep reads new ground, that a grid cell returning exactly its limit is a floor rather than a total, or that a geocoded viewport is a display rectangle and not a boundary. That knowledge decides whether an answer is right, and it lives in skills/geolink/:

PathWhat it holds
SKILL.mdseven gates from a map question to a defensible answer, in the order they have to run
references/tripwires.mdthe ten ways an answer comes back confidently wrong — each with what happened, the check, and what passing looks like
references/coverage.mdspacing maths and the three tests for whether an area was actually covered
references/cost.mdcost formulas and measured latency
references/recipes.mdcompositions: reachability, territory, service gaps, on-the-way
scripts/probe.mjsre-measures every number in those files against the live API and flags drift
ledger/coverage claims made, and how each was verified

Nothing in there is asserted from intuition. Every tripwire is an incident that happened, and every figure is re-derivable by running the probe — because a reference whose numbers rot is worse than none, the rot being invisible.

The server serves the same files as resources, generated from them at build time, so a chat model or a harness with no filesystem reads exactly what an IDE agent reads. A test asserts the served text is byte-identical to the file on disk; the two cannot drift apart without CI failing.

🧬 Extending

GeoLink occasionally adds fields (website, phone, category, rating…) or raises per-query result counts. There are exactly four places to touch:

  1. If the change is about how many results a search returns rather than a new field, it lives in one place: the maxResults argument of client.textSearch (src/services/client.ts) and the UPSTREAM_PAGE_SIZE / MAX_SEARCH_DEPTH constants it reads.
  2. Add the raw field to RawPlace in src/types.ts and the clean field to Place.
  3. Map it in normalizePlace (src/services/normalize.ts) with a safe default.
  4. Add it to PlaceSchema (src/services/schemas.ts) and, if it should be trimmable, to the fields enum in src/tools/sweep.ts.
  5. Render it in placeMarkdown (src/services/format.ts).

Nothing else changes — every tool flows through those four points. Details and a PR checklist live in CONTRIBUTING.md.


❓ FAQ


Contributing · Security · Changelog · Code of Conduct · MIT License

Built on GeoLink · Speaks Model Context Protocol

↑↓ navigate openesc close