Skip to main content
Glama
imelendez

ZIP↔County Crosswalk MCP Server

by imelendez
README.md
# ZIP↔County Crosswalk MCP Server

A Model Context Protocol (MCP) server that lets Claude look up ZIP-to-county
and county-to-ZIP relationships, using HUD's official USPS ZIP Code
Crosswalk API — including residential-address overlap filtering, since ZIP
codes routinely cross county lines.

## Architecture

```mermaid
flowchart LR
    Claude -->|MCP tool call| Server[zip-county-mcp server]
    Server -->|overlap ratios| HUD[HUD USPS Crosswalk API]
    Server -->|county names| BQ[(BigQuery:\ngeo_us_boundaries)]
```

## Why this exists

ZIP codes and counties don't line up cleanly — a single ZIP can spread
across several counties, each holding a different share of that ZIP's
residential addresses. Most simple ZIP↔county lookups ignore this and just
return one answer, which is often wrong for the county holding a small
sliver of the ZIP. This server exposes HUD's real overlap-ratio data so a
caller can filter out negligible slivers via a `min_overlap_pct` threshold,
and get accurate county names via a BigQuery public dataset.

Example: ZIP `77494` (Katy, TX) is actually split across three counties —
Fort Bend (83.4%), Harris (16.5%), and Waller (0.17%). A caller who only
wants counties that meaningfully make up that ZIP can set
`min_overlap_pct=5` and get back just Fort Bend and Harris.

### How overlap is actually measured

The overlap percentage comes straight from HUD's `res_ratio` field, and
it's worth being precise about what that field is (verified against
[HUD's own API docs](https://www.huduser.gov/portal/dataset/uspszip-api.html),
not assumed):

- It's a ratio of **residential addresses**, not population/headcount. A
  single-person address and a five-person address both count as "1" toward
  the ratio — addresses are a reasonable proxy for population, but not
  the same measurement.
- **The denominator flips with query direction.** For `zip_to_county`
  (HUD `type=2`), `res_ratio` is addresses-in-this-county ÷
  addresses-in-**the-whole-ZIP**. For `zips_in_county` (HUD `type=7`, the
  reverse lookup), it's addresses-in-this-ZIP ÷ addresses-in-**the-whole-
  county**. Same field name, different denominator — which is why a
  populous county like Harris shows dozens of ZIPs at only 1-3% each,
  while a single ZIP can show one county at 80%+: the two percentages
  aren't measuring against the same total.

## Status

All three tools are implemented, tested (7 passing tests, `pytest`), and
verified end-to-end against live HUD + BigQuery data and a real Claude
Desktop connection.

## Setup

1. Get a free HUD API account and Bearer token at
   [huduser.gov](https://www.huduser.gov/hudapi/public/usps).
2. Confirm you have BigQuery access to `bigquery-public-data.geo_us_boundaries`
   (e.g. via `gcloud auth application-default login`).
3. Copy `.env.example` to `.env` and fill in `HUD_API_TOKEN` and your Google
   Cloud project.
4. Create a virtualenv with **Python 3.10+** (the `mcp` package requires it —
   on macOS the system `python3` is often older, so check `python3 --version`
   first) and install dependencies:
   ```
   python3 -m venv .venv
   .venv/bin/pip install -r requirements.txt
   ```
5. Run it: `.venv/bin/python3 server.py`

### Connecting to Claude Desktop

Add an entry to your `claude_desktop_config.json` (macOS:
`~/Library/Application Support/Claude/claude_desktop_config.json`):

```json
"mcpServers": {
  "zip-county-mcp": {
    "command": "/absolute/path/to/zip-county-mcp/.venv/bin/python3",
    "args": ["/absolute/path/to/zip-county-mcp/server.py"]
  }
}
```

Fully quit and reopen Claude Desktop (MCP servers only load at startup), then
try one of the questions below.

## Example usage

Once connected, just ask Claude in plain English — it picks the right tool
and arguments on its own. A few real examples (verified against live data):

**Look up a single ZIP:**
> "What county is ZIP 77002 in?"

Claude calls `zip_to_county("77002")` → ZIP 77002 (Houston, TX) is entirely
in Harris County, TX (FIPS 48201) — 100% overlap.

**A ZIP that crosses county lines:**
> "What counties does ZIP 77494 overlap, and by how much?"

Claude calls `zip_to_county("77494")` → three counties: Fort Bend (83.4%),
Harris (16.5%), Waller (0.17%). Ask a follow-up like *"only ones with at
least 5%"* and it re-calls with `min_overlap_pct=5`, dropping Waller's
negligible sliver.

**Reverse lookup — ZIPs inside a county:**
> "What ZIP codes are in Harris County, Texas?" (or give it the FIPS code,
> 48201, directly)

Claude calls `zips_in_county("48201")` → a list of every ZIP holding a
meaningful share of Harris County's residential addresses, sorted by
overlap.

**A list of ZIP codes at once:**
> "What counties are ZIP codes 77002, 77494, and 10001 each in?"

Claude calls `batch_zip_to_county(["77002", "77494", "10001"])` → one
result per ZIP in a single response, without querying BigQuery once per
ZIP behind the scenes.

## Tools (v1 scope)

- `zip_to_county(zip_code, min_overlap_pct=0)` — county/counties for a ZIP, with % overlap
- `zips_in_county(county_fips, min_overlap_pct=0)` — ZIP codes in a county, with % overlap
- `batch_zip_to_county(zip_codes, min_overlap_pct=0)` — same as above, multiple ZIPs at once

Out of scope for v1: census tract-level lookups, CBSA/congressional district
crosswalks, caching layer, other geography types.

## Testing

```
.venv/bin/pip install -r requirements.txt
.venv/bin/python3 -m pytest tests/ -v
```

Tests mock HUD's API responses (`httpx.MockTransport`) and the BigQuery
name lookup, so they run in about a second with no live token or BigQuery
access required.