← All posts

Where are the voters?

A five-step tutorial. Real data, real maps, no Python notebook.

You're sitting on a list of 2,000 donor addresses. Your boss wants to know: which congressional districts do they live in, what's the median income of those districts, and what's the closest field office to each donor's home? You're an analyst, not a GIS engineer.

The old answer: download a TIGER shapefile, install GDAL, write a Python notebook with geopandas, manually pull a Census table, geocode the addresses with a separate service, do the spatial join, render the map with matplotlib, hand it off as a PNG. Half a day of glue code.

The new answer: ask Claude.

This walkthrough uses Maps MCP — a single platform that gives you versioned U.S. geography, Census data joined to it, and 80+ tools for spatial analysis under one API key. (It's also a Model Context Protocol server, so Claude Desktop and Cursor users can drive it from chat — but the workflow below is the same whether you call it from code or from an agent.)

# One-time setup
brew install jq
export MAPSMCP_KEY=mp_live_...     # from mapsmcp.com signup

You can use Claude Desktop, Cursor, Claude Code, or the Anthropic SDK — anywhere MCP works. The examples below show the raw JSON-RPC for clarity; in practice you just ask the agent in plain English and it picks the right tool.

Step 1 — Geocode the donor addresses

The first task is the unsexy one: turning street addresses into congressional districts. bulk_match_addresses does it in one call — batched geocoding via the U.S. Census geocoder (free, no key), then point-in-polygon against the 119th Congress shapes.

{
  "method": "tools/call",
  "params": {
    "name": "bulk_match_addresses",
    "arguments": {
      "records": [
        { "id": "donor-1", "street": "1600 Pennsylvania Ave", "city": "Washington", "state": "DC" },
        { "id": "donor-2", "street": "350 Fifth Ave", "city": "New York", "state": "NY" }
        // ... 1,998 more
      ],
      "collection": "us-cd119",
      "version": "tiger-2024"
    }
  }
}

Output: one row per donor with the matched congressional district, the geocoder's normalized address, and the lat/lng. Your original record id is echoed back so you can join it to your CRM.

Step 2 — Ingest as a dataset

Store the geocoded donors as a workspace dataset so we can run analytics against the joined shape data:

{
  "name": "ingest_dataset",
  "arguments": {
    "slug": "campaign-donors-2024",
    "records": [/* the bulk_match_addresses output */],
    "shape_collection": "us-cd119",
    "shape_version": "tiger-2024"
  }
}

Now donors-2024 lives in your workspace, joined to the 441 districts. Every donor knows which CD they're in; every CD knows how many donors it has.

Step 3 — Pull Census demographics for those districts

Want to know median household income by CD? census_acs wraps the Census ACS API and joins to the same shapes:

{
  "name": "census_acs",
  "arguments": {
    "collection": "us-cd119",
    "version": "tiger-2024",
    "year": 2023,
    "variables": ["B19013_001E", "B01001_001E", "B03002_003E"],
    "slug": "cd119-acs-2023"
  }
}

That's median household income (B19013_001E), total population (B01001_001E), and non-Hispanic white count (B03002_003E) for every CD in the 119th Congress, ingested as a workspace dataset. Free upstream, no API key required.

Step 4 — Build the map

Two operations. First, a choropleth showing donor density by CD:

{
  "name": "create_report",
  "arguments": {
    "slug": "donor-density-2024",
    "name": "Campaign donor density by congressional district",
    "public": true,
    "config": {
      "version": "tiger-2024",
      "collection": "us-cd119",
      "datasets": [{
        "slug": "campaign-donors-2024",
        "field": "donor_count",
        "agg": "sum",
        "palette": "sequential",
        "classify": { "method": "quantile", "bins": 7 }
      }]
    }
  }
}

You get a public URL of the form /v/<your-slug>. Pan, zoom, hover — vector tiles via PostGIS ST_AsMVT. Embeddable as an iframe; fetch as PNG with /v/<your-slug>.png?width=1200 for slide decks.

Want a 20-minute-drive catchment around your San Francisco field office? That's geo_isochrone:

{
  "name": "geo_isochrone",
  "arguments": {
    "point": [-122.42, 37.77],
    "minutes": [20],
    "profile": "driving",
    "provider": "mapbox",
    "api_key_secret": "mapbox"
  }
}

Then intersect that polygon with your donor dataset (geo_intersect_dataset) to get the list of donors actually inside the catchment.

Step 5 — Get Claude to tell you what the map says

The interesting one. describe_report runs the report through Claude Opus 4.7 and returns a plain-English narrative — for captions, briefs, or "send me a paragraph summary" requests:

{
  "name": "describe_report",
  "arguments": { "report": "" }
}

The response is a short prose summary that names the highest- and lowest-value regions on the rendered map, with the underlying numbers cited inline. Useful for press-release first drafts, brief memos, or "give me a paragraph" requests inside a chat agent. The output is grounded in the actual published report — the same data the iframe shows.

That's it. Five operations, no geopandas, no matplotlib, no notebook. Each one is the kind of thing analysts have been doing for decades — Maps MCP pays the integration tax once so you can chain them as a single workflow.

Try it yourself

Free tier — 50 calls / month, no card.

Get an API key