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.
Set up
Runs on your computer. Pick your agent and operating system.
Before you start
- Node.js 20 or newer
{
"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
Turn any AI agent into a location expert — geocoding, search, routing, and full-area sweeps over the Model Context Protocol
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-mcpis not on the npm registry yet, sonpx -y geolink-mcpwill 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 page | geolink_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 point | geolink_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 bridges | geolink_find_nearest — ranks candidates by real road travel time, not centimeters on a map |
| A wall of raw coordinate arrays for every route | geolink_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 trace | A 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.
| Tool | What it answers | Upstream calls |
|---|---|---|
🔎 geolink_geocode | "Where is this address?" → one place, structured address_parts, viewport bounds | 1 |
📍 geolink_reverse_geocode | "What's at these coordinates?" → address with address_parts | 1 |
🗺️ geolink_search_places | "Find category/brand near here" → as deep as limit asks, sorted by distance | ceil(limit/20) (+1 if near is a name) |
🛣️ geolink_get_directions | "How do I get from A to B?" → alternatives, geometry opt-in | 1 (+1 per named endpoint) |
🧮 geolink_distance_matrix | "Travel time between these N and those M?" → grid + nearest-per-origin | 1 (+1 per named location) |
🎯 geolink_find_nearest | "Which of these is really closest by road?" → ranked by real duration | 1 matrix (+1 search in discovery mode) |
🕸️ geolink_sweep_area | "Find everything of this kind in this region" → grid-tiled, de-duplicated, dry_run first | grid 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_detail | Returns | Use for |
|---|---|---|
summary (default) | distance, duration, endpoints, bounds, waypoint_count | ETAs, comparisons, planning |
polyline | summary + Google-encoded polyline (precision 5) | drawing on a map — ~10× smaller than raw points |
waypoints | summary + [lat,lng][], evenly sampled to max_waypoints | when 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:
- Resolve the area to bounds —
{place: "..."}via geocode viewport,{center, radius_km}, or raw{bounds}— optionally padded bypadding_km. - Tile it with a square grid, one query point per
grid_spacing_km × grid_spacing_kmcell (longitude corrected for latitude; circular areas drop the corner points). - Search once per grid point with bounded concurrency — individual point failures are tolerated, auth/quota errors abort the whole sweep immediately.
- 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 bygovernorateanddistrict. - Shape the output — page with
limit/offset, trim fields withfields, or emit a ready-to-render GeoJSONFeatureCollection.
Always run
dry_run: truefirst. It returns the exact grid size and call count without touching the search endpoint — no surprise bills, no wasted quota. If the grid exceedsGEOLINK_SWEEP_MAX_POINTS, the error message tells you the smallestgrid_spacing_kmthat 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 —outputSchemavalidation 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/countryare 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:
| Prompt | Arguments | Drives |
|---|---|---|
geolink_coverage_report | category, area | dry-run → tune spacing → sweep → per-district summary with gap analysis |
geolink_nearest_branch | customer_location, branches | find_nearest + recommendation, flags when straight-line and road-time disagree |
geolink_route_brief | origin, destination | directions with polyline + detour-ratio sanity check |
geolink_coverage_audit | category, area | sweep, then test the result for saturation, overlap and edge loss before reporting a number |
geolink_service_gap | service, demand_proxy, area | two comparable sweeps → demand-versus-supply ranking → drive time to the nearest existing one |
📚 Resources
| URI | Contents |
|---|---|
geolink://method | The seven gates from a map question to a defensible answer, in the order they have to run |
geolink://playbook | Which tool answers which question, and the distinction that causes most wrong answers: depth reads one center deeper, a sweep reads new ground |
geolink://playbook/coverage | The 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/tripwires | The ten ways a map answer comes back confidently wrong, each with its check |
geolink://playbook/recipes | Compositions across several tools: reachable-area approximation, territory assignment, underserved-ground analysis, on-the-way search, address-confidence checks |
geolink://playbook/cost | Cost formulas, measured latency, and the planning rules that follow |
geolink://scale | Measured call counts, latency and response variance against the live upstream |
geolink://capabilities | Endpoints, active defaults, cost formulas, the two guard rails that can refuse a request, and every error kind |
geolink://egypt/governorates | The 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
| Variable | Default | Notes |
|---|---|---|
GEOLINK_API_KEY | — | Required. Free tier, no card. Passed to GeoLink as the key query parameter — never logged, never echoed in output. |
GEOLINK_BASE_URL | https://www.geolink-eg.com | Override for staging or the bundled mock server. |
GEOLINK_DEFAULT_LANGUAGE | en | Any tool call can override with language. |
GEOLINK_DEFAULT_COUNTRY | (none) | Any tool call can override with country; unset means no bias. |
GEOLINK_TIMEOUT_MS | 30000 | Per upstream request. |
GEOLINK_MAX_MATRIX_CELLS | 100 | origins × destinations cap for matrix tools. |
GEOLINK_SWEEP_MAX_POINTS | 200 | Max grid points (= max API calls) per sweep. |
GEOLINK_SWEEP_CONCURRENCY | 4 | Parallel requests during a sweep. |
TRANSPORT | stdio | stdio or http. |
HOST / PORT | 127.0.0.1 / 3000 | HTTP 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/:
| Path | What it holds |
|---|---|
SKILL.md | seven gates from a map question to a defensible answer, in the order they have to run |
references/tripwires.md | the ten ways an answer comes back confidently wrong — each with what happened, the check, and what passing looks like |
references/coverage.md | spacing maths and the three tests for whether an area was actually covered |
references/cost.md | cost formulas and measured latency |
references/recipes.md | compositions: reachability, territory, service gaps, on-the-way |
scripts/probe.mjs | re-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:
- If the change is about how many results a search returns rather than a new field, it lives in one place: the
maxResultsargument ofclient.textSearch(src/services/client.ts) and theUPSTREAM_PAGE_SIZE/MAX_SEARCH_DEPTHconstants it reads. - Add the raw field to
RawPlaceinsrc/types.tsand the clean field toPlace. - Map it in
normalizePlace(src/services/normalize.ts) with a safe default. - Add it to
PlaceSchema(src/services/schemas.ts) and, if it should be trimmable, to thefieldsenum insrc/tools/sweep.ts. - 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