Skip to main content
Glama
README.md
# gmaps-mcp

A **free Google Maps toolkit**, in pure Python, no API key. It hits
Google's own internal endpoints directly — no headless browser — for
business search, single-place lookup, and multi-mode travel directions.
Ships as a CLI (`gmaps`) and as an **MCP server** (`gmaps-mcp`) so any
MCP-capable coding tool can pull Google Maps data straight into a
conversation.

Search and lookup extract full business profiles with asyncio concurrency
and export to **JSON + CSV**; directions returns travel duration and
distance between two places. Built to be the strong, fast, self-hosted
option — see [Roadmap](#roadmap) for where this is headed.

## Features

**Business search & lookup**
- **No API key, no paid service** — direct `tbm=map` protobuf-over-JSON requests.
- **Full profile data** per business: name, rating, categories, address
  (split into street/city/state/postal/country), coordinates, **phone**,
  **website**, **operating hours**, **description**, **plus code**,
  **status** (open/closed), **attributes** (women-led, LGBTQ+ friendly…),
  **photos + thumbnail**, derived **CID**, **reviews link**, **Street View
  link**, Google Maps link.
- **Free-endpoint limits (honest nulls):** review count, price range,
  reservation/ordering links, owner-claimed flag and payment info are not
  exposed by Google's free endpoints — stored empty rather than guessed.
- **Website enrichment**: crawls each business's site (deduped by domain) for
  **email addresses** and **social links** (Instagram, Facebook, X/Twitter,
  LinkedIn, TikTok, YouTube).
- **Concurrency** via asyncio (parallel requests + retries + backoff).
- **Pagination**, **whole-country sweep** on a lat/lng grid, **multi-keyword
  passes** (deduped by feature id), **single-entity lookup** by feature id /
  Google Maps URL / name.
- **JSON + CSV** output with normalized fields — machine CSV for scripts, a
  separate human-readable summary CSV alongside it.

**Directions**
- **Travel duration + distance** between two places: driving, bicycling,
  walking, transit — all real, no API key (flying is a separate Google
  product, not available here).

**MCP server** (`gmaps-mcp`): `search`, `lookup` and `directions` tools, same data.

## Install

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
```

Optional extras:

```bash
pip install -e ".[socks]"   # SOCKS proxy support (--proxy socks5://...)
pip install -e ".[mcp]"     # the gmaps-mcp server
pip install -e ".[dev]"     # tests (pulls in [mcp] too, see Development)
```

## MCP server — connect it to your coding tool

`gmaps-mcp` runs over stdio and exposes three tools: `search` (keywords,
optionally swept over a country/bbox grid), `lookup` (one place by
feature id / Google Maps URL / name), and `directions` (travel duration
between two places — all ground modes, with distance for driving/transit;
see the note below).

**Install the package first** — `pip install -e ".[mcp]"` (above). The
`gmaps-mcp` binary must already be on `PATH` (or callable via `python -m
gmaps.mcp_server`) before any of the steps below will work: none of these
commands install `gmaps-mcp` itself, they only register an already-installed
binary with the tool's own config so it knows to launch it.

**Claude Code** — one line, no file editing:
```bash
claude mcp add gmaps -- gmaps-mcp
```
(add `--scope user` to make it available in every project, not just this one)

**Codex CLI**:
```bash
codex mcp add gmaps -- gmaps-mcp
```
or in `~/.codex/config.toml`:
```toml
[mcp_servers.gmaps]
command = "gmaps-mcp"
```

**Cursor** — `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
```json
{ "mcpServers": { "gmaps": { "command": "gmaps-mcp" } } }
```

**DeepSeek Harness (`dsh`)** — MCP servers are mounted as patches in
`~/.dsh/profiles/web/cordis.patch.yml`:
```yaml
- insert:
    - id: mcp-gmaps
      name: '@deepseek-ai/dsh-mcp-client'
      config:
        serverName: gmaps
        transport: stdio
        command: gmaps-mcp
```
Restart `dsh` after saving (`dsh web --dump-config | grep -A3 mcp` to
confirm it loaded).

**OpenCode** — add under `mcp` in your OpenCode config (`opencode.json` /
`opencode.jsonc`):
```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "gmaps": { "type": "local", "command": ["gmaps-mcp"] }
  }
}
```

**Hermes Agent**:
```bash
hermes mcp add gmaps --command gmaps-mcp
```
or directly in `~/.hermes/config.yaml`:
```yaml
mcp_servers:
  gmaps:
    command: gmaps-mcp
```
Then `hermes mcp test gmaps` to confirm, or `/reload-mcp` in a running session.

## Directions (travel duration + distance between two places)

`gmaps --directions "ORIGIN" "DEST"` (CLI) and the `directions` MCP tool
return travel duration for **driving, bicycling, walking and transit**,
plus real **distance** for driving and transit — no API key. Under the
hood this queries Google's internal directions endpoint twice, in two
different request shapes, because they aren't interchangeable: one gives
duration for all four ground modes on a consistent, toll-averse route,
but its transit entry is commonly empty; the other gives real distance +
duration for driving and transit (transit's includes actual
departure/arrival times), but its driving route can include tolls — a
**different physical route** than the first shape's, confirmed live, not
a rounding difference. Because of that: driving's *duration* always comes
from the consistent (toll-averse) shape, matching bicycling/walking;
driving's *distance* is added from the other shape only when available,
flagged `distance_route_may_differ: true` so it's never presented as
describing the exact same trip as the duration. Transit comes entirely
from the distance-carrying shape (no mixing). **Flying is not
available** — it's a separate Google Flights product, not part of this
endpoint at all, and a null there says nothing about whether a flight
actually exists.

```bash
gmaps --directions "Paris, France" "Zurich, Switzerland"
```

## CLI usage

```bash
# single keyword, full Google Maps profile, save JSON+CSV (default: no website crawl)
gmaps "pizza in Berlin" --out leads

# optionally crawl each business's website for emails + social links
gmaps "pizza in Berlin" --website --out leads_with_contacts

# add emails + socials to an EXISTING results JSON (no Google re-scrape)
gmaps --enrich-file leads.json --out leads_contacts

# faster: skip place-detail enrichment (search fields only)
gmaps "pizza in Berlin" --no-enrich

# 3 pages (≈60 results) of one query
gmaps "restaurants in Berlin" --pages 3

# sweep a whole country on a 30km grid
gmaps "dentist" --country DE --spacing 30 --out germany_dentists

# long sweep: write a partial <out>.checkpoint.json every 50 cells,
# so an interruption never loses the whole run
gmaps "dentist" --country DE --spacing 30 --checkpoint-every 50 --out germany_dentists

# custom bounding box (min_lat min_lng max_lat max_lng)
gmaps "cafe" --bbox 48.0 11.0 49.0 13.0 --spacing 15

# multi-keyword: several terms in one pass, deduped by feature id
gmaps "pizza" --keywords "kebab" "italian restaurant" --out multi_food

# single-entity lookup: full profile by feature id / maps URL
gmaps --lookup "0x47a8...:0x4bff..."
# ... or top-5 candidates by name (disambiguate, then look up by fid)
gmaps --lookup "Joyería Serrano" --top-n 5

# list supported countries
gmaps --list-countries

# tune concurrency / optional proxy (SOCKS needs the [socks] extra, see Install)
gmaps "barber" --country FR --concurrency 8 --proxy socks5://127.0.0.1:1080
```

Run as a module if you prefer: `python -m gmaps "pizza in Berlin"`.

## Output

`<out>.json` — array of full records.
`<out>.csv` — flat table with columns for every field plus one per social network.
`<out>.summary.csv` — human-oriented table (written alongside, same rows):
  `name` first, split `street`/`city`/`state`/`postal_code`/`country`,
  compact `hours` plus a separate `closed_days` column, rounded coordinates,
  machine ids (`query`, `google_maps_url`) at the end. Semicolon-delimited,
  plain UTF-8 (no BOM): spreadsheets in comma-decimal locales (FR/DE/MA...)
  split CSV on `;` and would show a comma file as one column; a comma file
  is still written by the machine export above.

Every record carries `google_maps_url` (a working link to the place).
The machine `<out>.csv` layout is stable for scripts; the summary is meant to
be read.

## Repository structure

```
gmaps-mcp/
├── src/gmaps/
│   ├── cli.py           argument parsing + orchestration (the `gmaps` command)
│   ├── __main__.py       thin entry point (python -m gmaps)
│   ├── mcp_server.py     the `gmaps-mcp` entry point: search + lookup + directions tools
│   ├── config.py        shared headers, user-agent, endpoint URLs, constants
│   ├── client.py        async HTTP client (retries, circuit breaker, proxy validation)
│   ├── pb.py             protobuf field-selector construction (search, detail, directions)
│   ├── parser.py        schema-tolerant structural parser for the protobuf JSON
│   ├── models.py        Place / SearchResult dataclasses
│   ├── pipeline.py      concurrency, pagination, enrichment, grid sweeps, lookup, directions
│   ├── grid.py           country bounding boxes + lat/lng grid
│   ├── webenrich.py     website crawl for emails + socials (deduped by domain)
│   └── export.py        JSON + CSV writers (machine CSV + human summary CSV)
├── tests/                pytest suite — parser, grid, export, pipeline, features,
│                         mcp, directions; all offline (fixtures, stub clients, in-process MCP calls)
├── data/                 your scraped output lands here (gitignored)
├── .github/workflows/    CI: install + full suite on Python 3.10–3.12
├── AGENTS.md             invariants and gotchas for anyone editing this codebase
├── CONTRIBUTING.md       ground rules for contributions
├── llms.txt              machine-readable project index (llms.txt v2 spec)
├── pyproject.toml        package metadata, dependencies, entry points
├── requirements.txt      plain pip-freeze-style mirror of pyproject's deps
└── LICENSE               MIT
```

## Disclaimer — this is gray-zone scraping

This project queries `https://www.google.com/search?tbm=map` and
`https://www.google.com/maps/preview/place` — **internal, undocumented
Google endpoints**, not a published or licensed API. There is no API key
because there is no API: this is the same request your browser makes when
you use Google Maps, replayed programmatically. That distinction matters:

- **Google's Terms of Service** generally prohibit automated access to their
  services outside of the official APIs. Using this tool may violate those
  terms; Google can and does rate-limit or block IPs it identifies as
  scraping (the client's circuit breaker exists because of this, not despite
  it).
- **The endpoints are unstable by design** — they can change shape, require
  new headers, or disappear without notice, because Google owes no backward
  compatibility to a request format it never published.
- **Data protection law varies by jurisdiction.** Business listings are
  generally public information, but what you may collect, store, and do
  with scraped personal data (an owner's name in a listing, a phone number)
  depends on where you and your data subjects are — GDPR, CCPA, and similar
  regimes may apply.
- **This is not legal advice.** Review the copyright/ToS/data-protection
  position for your jurisdiction and use case before scraping at any scale.
  The authors and contributors accept no liability for how this tool is
  used; see `LICENSE` (MIT — provided as-is, no warranty).

If you need guaranteed uptime, an SLA, or unambiguous legal footing, use
Google's official [Places API](https://developers.google.com/maps/documentation/places/web-service)
instead — it costs money precisely because it buys you those things.

## Development

See [CONTRIBUTING.md](./CONTRIBUTING.md) for the ground rules (tests-first,
fixtures from real bodies, honest nulls, flag threading).

```bash
pip install -e ".[dev]"      # install package + pytest (pulls in the mcp extra too)
python -m pytest tests/ -q   # run the test suite (72 tests, all offline)
```

## Architecture

See [Repository structure](#repository-structure) above for what each file
does. `AGENTS.md` documents the invariants that aren't obvious from reading
any single file — read it before making structural changes.

## Roadmap

Search, lookup and directions all sit on the same family of Google Maps
internal endpoints — the pattern for adding a capability here is
consistent: find the request shape via a real browser session, confirm it
live against several inputs (never trust one), write a structural parser,
ship honest nulls for whatever the free endpoint doesn't expose. Natural
next additions on that same pattern, roughly in order of how well-trodden
the endpoint already looks from this session's exploration:

- **Geocoding / reverse geocoding** — address ↔ coordinates, no full
  business lookup needed. Likely a thin wrapper around the same
  place-resolution step `search`/`lookup` already do.
- **Elevation** — a single lat/lng in, a height out; small, self-contained.
- **Place photos & reviews at scale** — `lookup` already returns photo
  URLs and a `reviews_link`; a dedicated `reviews` tool exists at the
  client layer (`MapsClient.reviews`) but isn't wired into MCP/CLI yet.
- **Distance for bicycling/walking** — currently duration-only (see
  Directions above); the request shape that carries distance for
  driving/transit doesn't carry these two modes at all, so this needs a
  genuinely different endpoint angle, not just a tweak.
- **Batch directions** (many origins → one destination, or a matrix) —
  useful for "which of these N places is closest," built on top of the
  existing single-pair `directions` rather than replacing it.

None of these are promised or scheduled — this list exists so a
contributor (human or agent) doesn't have to rediscover where the natural
seams are. See `CONTRIBUTING.md` for how a new capability should be built
here: tests first, fixtures from real captured bodies, honest nulls.

## How it works (briefly)

Google Maps search can be queried at `https://www.google.com/search?tbm=map`
which returns a large nested JSON array (protobuf-over-JSON) prefixed with an
anti-XSSI marker. Each business is a sub-array anchored on a feature id like
`0x47a8...:0x4bff...`. The parser decodes the JSON once and then walks the
tree, extracting fields *structurally* (rather than by brittle fixed indices
or regexes over the raw dump), so it survives schema shifts AND JSON escaping
(names with quotes or accents). Coordinates are embedded in the `pb` parameter
to pin the map viewport, and pagination is done by incrementing the `!8i`
offset.

## Notes & risks

- **review_count** is not available through the free protobuf endpoints from a
  residential IP: the `tbm=map` search response omits it, the place-detail
  response omits it, and the reviews endpoint returns 404 for unauthenticated
  requests. The scraper reports it honestly as null rather than guessing. To
  capture review counts, feed the `feature_id`/`google_maps_url` into a browser
  renderer (the count is shown on the place HTML page).
- This works from a residential IP; Google may rate-limit heavy use. The client
  includes a circuit breaker (pauses after repeated 403/429/503 blockages;
  timeouts and other errors retry without tripping it) and capped exponential
  backoff. Use `--concurrency`/`--respect-delay` sensibly for very large sweeps.
- Proxies: pass a URL string (`--proxy socks5://host:1080`). SOCKS additionally
  needs the `[socks]` extra; the scraper validates this up front and exits
  with an install hint instead of failing mid-sweep.
- The protobuf schema changes over time; the structural parser is designed to
  degrade gracefully (missing fields stay null) rather than crash. Hours are
  parsed from locale-dependent weekday blocks (Spanish `lunes` and English
  `Monday` alike); descriptions only when Google actually publishes an
  editorial summary for the place — ad lines, internal ids and UI labels are
  excluded, and a missing description stays null rather than being guessed.
- The place-detail parser is ad-proof: it skips third-party booking domains
  (Booking.com, Expedia, OpenTable...) for websites and takes the most-frequent
  phone, so ad data never leaks into a business record.