mcp-fairrent
# mcp-fairrent
MCP server for HUD housing data. Fair Market Rents by bedroom count (per area or a whole state at once), Section 8 income limits by household size, the LIHTC/MTSP income bands tax-credit buildings use, computed affordability verdicts, and the USPS ZIP-to-jurisdiction crosswalk in both directions (ZIP to county/tract/metro/district, and any of those back to its ZIPs). Built on the [MCP TypeScript SDK](https://modelcontextprotocol.io).
For anyone answering "is this rent affordable here, and who qualifies for help?": tenant organizers, legal-aid intake, housing counselors, relocation planners, and agents that need real HUD numbers.
## Tools
| Tool | What it does |
|------|--------------|
| `fmr_lookup` | Fair Market Rent for an area, by bedroom count (efficiency through 4BR). |
| `income_limits` | The 30% / 50% / 80% AMI income thresholds for an area; pass a household size for the one line that applies. The 50% line is the usual Section 8 voucher cutoff. |
| `affordability_check` | The computed verdict: how far a rent sits above or below FMR for a bedroom size (dollars and percent), and which income bands (30/50/80% AMI) a household qualifies under. The arithmetic is done server-side, with the underlying numbers and table year for citation. |
| `zip_crosswalk` | Map a ZIP to the county, tract, CBSA, CBSA division, congressional district, or county subdivision it sits in, with city/state and all four of HUD's address shares so you pick the right one. |
| `list_counties` | Counties in a state with their FIPS entity ids, to look up by county name. In New England, one row per town, with the town name and a combined area label. |
| `list_metro_areas` | HUD metro areas (CBSAs) with their codes. |
| `mtsp_income_limits` | The LIHTC (tax-credit building) income bands at 20-80% AMI plus the HERA special bands (`/mtspil/data`). A different table from the Section 8 limits, governing a different housing stock. |
| `state_fmr_overview` | Every county's and metro's FMRs for a whole state in one call (`/fmr/statedata`), for comparing areas without one lookup per county. Also the way to get a working entity id in Connecticut, where the FMR table refuses the ids `list_counties` returns. |
| `geo_to_zips` | The reverse crosswalk: every ZIP inside a county, tract, metro, CBSA division, congressional district, or county subdivision, with residential-address shares and city/state. |
## Install
Nothing to clone. Point your MCP client at it and npm fetches it on first run:
```json
{
"mcpServers": {
"fairrent": {
"command": "npx",
"args": ["-y", "@haksanlulz/mcp-fairrent"],
"env": { "HUD_API_TOKEN": "your-hud-token" }
}
}
}
```
<details>
<summary>From source (contributors)</summary>
```bash
git clone https://github.com/haksanlulz/mcp-fairrent
cd mcp-fairrent
npm install
npm run build # emits dist/; the published bin is dist/index.js
```
`npm start` runs the TypeScript directly via [`tsx`](https://github.com/privatenumber/tsx) without building.
</details>
<details>
<summary>As an MCP Bundle (.mcpb)</summary>
For hosts that install MCP Bundles: the bundle declares the HUD token as a required, sensitive setting, so the host collects it in its own UI instead of you editing a JSON `env` block.
```bash
npm run build:mcpb # writes build/mcp-fairrent-<version>.mcpb
```
Open that file in the host. No bundle is attached to a release yet; build it from source.
</details>
## Token
Every tool needs a free HUD USER API token. One-screen signup at [huduser.gov](https://www.huduser.gov/portal/dataset/fmr-api.html) → set `HUD_API_TOKEN`. The tools tell you so if it's missing.
`HUD_CONTACT` (optional) sets the contact string in the User-Agent sent to HUD; defaults to this repo's URL. Nothing loads a `.env` file. Set these in the shell or the MCP client's `env` block (`.env.example` lists them all).
## Environment
| Variable | Default | What it does |
|---|---|---|
| `HUD_API_TOKEN` | (none) | Required by every tool. Free HUD USER token. |
| `HUD_CONTACT` | this repo's URL | Contact string in the User-Agent sent to HUD. |
| `HUD_HTTP_ATTEMPTS` | `3` | Attempts per HUD request, 1-10. Only 429, 5xx and transport errors are retried. |
| `HUD_RETRY_BACKOFF_MS` | `500,2000` | Wait before each retry, comma-separated milliseconds, each `0`-`60000`. Past the end of the list the last value repeats. |
| `HUD_CACHE_TTL_MS` | `86400000` (24h) | Response-cache lifetime in milliseconds, `0` or more. `0` turns the cache off. |
| `HUD_CACHE_MAX` | `300` | Most responses kept before the oldest is dropped; minimum `1`. To switch the cache off use `HUD_CACHE_TTL_MS=0`, not `HUD_CACHE_MAX=0`. A bound of zero would evict nothing, so it's refused. |
The numeric knobs take a whole number; `HUD_RETRY_BACKOFF_MS` takes a comma-separated list of them. A value that isn't one, or that falls outside the range stated above, is ignored: the default applies and one line goes to stderr saying so. One bad entry rejects the whole backoff list, never half of it.
## The flow
An address is usually a ZIP, but `fmr_lookup` and `income_limits` key on a 10-digit county entity id, so bridge the two:
1. `zip_crosswalk` with the ZIP, `to: county` → the county's 5-digit FIPS (ZIP `10451` → geoid `36005`, city `BRONX`, `res_ratio` 1).
2. `list_counties` for that state → the county's 10-digit entity id (Bronx County → `3600599999`).
3. `fmr_lookup` and `income_limits` with that entity id → the bedroom rents and the voucher line.
Worked example: *a Bronx landlord wants $2,600 for a 2-bedroom. Is that above Fair Market Rent, and would a family of three earning $48k qualify for a voucher here?* That's `affordability_check` in one call (the entity id with `rent: 2600, bedrooms: 2, income: 48000, household_size: 3`) and the answer comes back computed. This is the sentence a housing counselor writes down, quoted from the two `verdict` strings, sentence-cased and joined (the fields themselves lead lower-case and carry no terminal period):
> Rent $2,600 is $371 (12.5%) below the 2027 Fair Market Rent of $2,971 for a two-bedroom in Bronx County, NY. A 3-person household with annual income $48,000 in Bronx County, NY is very low income (at or below 50% of area median) under the 2026 HUD income limits — generally income-eligible for a Section 8 voucher.
Under those sit the numbers each one came from: the 30% line at $45,850 (does not qualify), the 50% line at $76,350 (qualifies; the usual voucher cutoff), the 80% line at $122,150 (qualifies). The model cites and the server does the arithmetic.
**Two table years in one paragraph, deliberately.** HUD publishes the two tables on separate cycles, and as of 2026-09-14 the FMR table answers 2027 while the income-limit table answers 2026. A combined call therefore carries `table_years: { fmr, income, mismatch }` at the top level, and each verdict names the year of the table that produced it, which is the safe way to quote a figure that goes stale annually: the year travels with the sentence. Asking for one year across both is refused by HUD on the income side (it doesn't downgrade quietly), and `affordability_check` keeps the half that answered: `rent_check` carries the 2027 verdict, and `income_check` comes back `answered: false` with no bands and a sentence saying to re-run with no year. That sentence doesn't claim to know which way the year was wrong. HUD answers the same `{"error":"Invalid year"}` for a year ahead of the income table and for one behind both tables (checked live 2026-09-14: `year=2027` and `year=2010` are byte-identical refusals, and `/fmr/data` refuses 2010 as well), so it names both. Nothing is reported as qualifying off a table that didn't answer.
**New England is the exception, and step 2 is where it bites.** HUD's FMR areas in CT, MA, ME, NH, RI and VT are towns, not counties, so `list_counties` returns one row per town (169 for Connecticut, 29 of them labelled "Hartford County") and `town_name` is the only thing telling them apart. Connecticut has gone further: it replaced counties with planning regions and HUD's crosswalk followed, so ZIP `06511` maps to geoid `09170` while `list_counties` for CT still returns legacy `090xx` ids, and none of its 169 rows carries the `09170` prefix. The two don't meet. `0917099999`, the FIPS + `99999` construction, is a 404 on both tables, and `0900952070`, the id `list_counties` gives for New Haven town, is a 404 on the FMR table (the income-limit table resolves it, so a combined call fails on one half only). `list_counties` says so in its own payload for CT. **For Connecticut, use `state_fmr_overview` and take the town's `code`**: New Haven town is `0917052070`, which both the FMR and income-limit tables answer on. The other five states aren't affected. The ids `list_counties` returns there do answer `/fmr/data`: `2502300170` (MA), `2302100100` (ME), `5000100325` (VT), `3301900260` (NH), `4400105140` (RI), each the first row its state's list returns, each a 200. The town half is regional; the redirect is Connecticut's renumbering alone. (Checked live, 2026-09-14.)
## Entity ids
`fmr_lookup` and `income_limits` take a 10-digit county FIPS (e.g. `3600599999`, which is county FIPS `36005` + `99999`) or a metro CBSA code. `zip_crosswalk` and `list_counties` turn a ZIP into one.
## Example
`zip_crosswalk` with `zip: "10451"`, `to: "county"`:
```json
{
"zip": "10451",
"to": "county",
"note": "res_ratio is the share of the ZIP's residential addresses in each geography; the highest-share county is usually the right entityid. bus_ratio, oth_ratio and tot_ratio are the same share for business, other and all addresses.",
"matches": [
{ "geoid": "36005", "city": "BRONX", "state": "NY", "res_ratio": 1, "bus_ratio": 1, "oth_ratio": 1, "tot_ratio": 1 }
],
"eligibility_scope": "HUD program tables, reproduced as published. Rent figures are Fair Market Rents, not a housing authority's payment standard; income figures are program eligibility lines, not a determination or an award. Confirm with the administering agency before relying on a number for a real household."
}
```
All four of HUD's ratios come through (residential, business, other, and the total) so `tot_ratio` can be reconciled against the parts it's made of. They're rounded to four decimal places. A ZIP with no crosswalk rows (retired, or PO-box-only) answers with an empty `matches` and a note saying so. That's an answer, not an error.
`36005` is Bronx County; `list_counties` with `state: "NY"` gives its entity id `3600599999`, which `fmr_lookup` and `income_limits` take.
## Limitations
- Numbers are HUD's published FMR and income-limit year tables, not live market rents.
- The two tables publish on different cycles, so a combined `affordability_check` can answer from two different years. It reports both in `table_years` and each verdict names its own; a single `year` that only one table has is refused by HUD, never approximated.
- In New England a HUD area is a town. `list_counties` returns one row per town, and in Connecticut the FMR table answers on the planning-region ids in `state_fmr_overview` rather than on the legacy county ids `list_counties` returns (see The flow).
- HUD's tables bound the inputs: bedrooms 0-4 (FMR tables stop at four bedrooms), household size 1-8 (income-limit tables stop at eight; `affordability_check`'s error gives HUD's convention for larger households).
- `affordability_check` compares a single FMR row. Areas whose FMR data comes back multi-row (small-area/ZIP-level, or multi-year) are refused; pass a county entityid, or use `fmr_lookup` to see every row.
- FMR isn't the voucher ceiling. Housing authorities set payment standards at 90-110% of FMR (24 CFR 982.503). The rent verdict carries this note.
- Every response carries an `eligibility_scope` note: these are program lines, not personal determinations, and an answer is exactly as current as its table year.
## Develop
```bash
npm test # vitest over an in-memory transport, fetch mocked (no network, no token)
npm run smoke # one live call per tool (needs HUD_API_TOKEN; skips without)
npm run typecheck
npm run build # emit dist/ (what actually ships)
npm run verify:pack # pack, install into a clean dir, drive the installed binary over stdio
npm run build:mcpb # stage + pack the .mcpb bundle into build/
npm run verify:mcpb # unpack that bundle and launch it the way a host does
```
## Testing
Two tiers, already split by script. `npm test` is the offline tier: vitest, in-memory MCP transport, `fetch` mocked, no token. `npm run smoke` is the live tier: one real HUD call per tool, needs `HUD_API_TOKEN`, exits 0 with a skip line without it. CI runs only the offline tier.
Counts, measured 2026-09-14 after the last change of the day:
```bash
find . -name '*.ts' -not -path './node_modules/*' -not -path './dist/*' -not -path './build/*' -not -path './test/*' -not -name smoke.ts | xargs wc -l # app: 1033 lines (index.ts + server.ts; smoke.ts is another 70)
find ./test -name '*.test.ts' | xargs wc -l # tests: 1594 lines, 2 files
npm test # 75 tests, 75 passed
```
`build/` is excluded because `npm run build:mcpb` stages a copy of the server there.
Layers. `test/server.test.ts` drives every tool end to end through the SDK client: input validation (bounds, required pairs, non-positive numbers) rejected before any HUD request; response shaping against fixtures copied from HUD's documented samples; the transport layer (retry on 429 and 5xx, no retry on 404, response cache keyed on path plus params, failures not cached). `test/no-http-stack.test.ts` pins that the source imports only the stdio transport, never an HTTP one, that `package.json` declares exactly one runtime dependency, and that nothing in the source writes to stdout, since that file descriptor belongs to JSON-RPC and diagnostics go to stderr.
Mutation probe, 2026-09-11: widened the `affordability_check` bedrooms bound in `server.ts` from `> 4` to `> 5`. One test went red: `affordability_check > enforces the table bounds: bedrooms 0-4, household_size 1-8`. 47 others stayed green. Source restored, `git diff --quiet -- server.ts` clean.
Probed again 2026-09-14, once per change landed that day: reverting the metro-status comparison, the statedata name chain, the `FMR Percentile` field, the `?? r.geoid` ZIP fallback, `town_name` on a county row, the town-first area label, the `table_years` block, the income-year error, and the validated env knobs each turned their own test red and nothing else. The retry tests were re-probed after the backoff ladder was flattened for speed: making a 404 retryable, and a 429 not, still fails them.
Fix round, same day. Two by mutation: deleting the backoff sleep from `withRetry` (the old timing test passed, its replacement fails) and deleting the retry-deadline break (the new deadline test hangs past its timeout). The rest were written red-first instead, which is the same evidence from the other side: the `list_counties` CT note, the Massachusetts note without the CT half, the kept rent verdict on a year the income table refuses, and the un-rewritten "Invalid year" each failed against the code as it stood before the change. The bundle probe was probed in both directions; GAUNTLET §6 has it.
Second fix round, same day. Three by mutation: dropping `?? backoffs[backoffs.length - 1]` from `withRetry` (the new ladder test goes red at 4349ms against a 2000ms bound, while the attempt count stays at 4 either way); moving the 2BR fixture rent one dollar (the pinned README verdict goes red); and restoring the hand-written entityid error (the schema-vs-error test prints the two strings side by side). The income-year rewrite was written red-first. The two bundle-script fixes are not test-visible and were measured directly: a spaced path under `shell: true` exits 1 unquoted and 0 quoted, and the probe's temp directory count goes 23 → 24 on the old code and 24 → 24 on the new, on both the PASS and the FAIL path.
Call-count assertions (`toHaveBeenCalledTimes`, `not.toHaveBeenCalled`) were audited 2026-09-11 at 12 sites: 12 kept, 0 pruned. Re-counted 2026-09-15 at 22. The rounds above added ten, and the audited figure was left standing under paragraphs that had moved what "the same day" pointed at. The ten: the backoff ladder's attempt counts, a malformed attempts knob still making the request, the cache TTL and cache-bound knobs, the two console-channel spies, and the entityid error firing before any network call. Each one pins a contract (which endpoint a call hit, validation firing before the network, retry counts, cache hits, which console method a diagnostic took). None of them asserts that a function ran. Policy: assert behavior and payloads, never bare invocation.
## AI assistance
This project was built with AI assistance (Claude). Correctness rests on the checks. The vitest suite drives every tool over the MCP in-memory transport against fixtures that mirror HUD's documented response samples, including this README's worked example, and `npm run smoke` makes one live call per tool against the real HUD API. I reviewed the code and I'm accountable for what it does.
## License
MIT © Abishai James. Data is public U.S. government data from the HUD USER API; this project is unofficial and not affiliated with HUD.
TDQS
Scored across 6 tools
Each tool has a distinct purpose: fmr_lookup for rent, income_limits for income thresholds, affordability_check for combined analysis, zip_crosswalk for geocoding, list_counties and list_metro_areas for geography enumeration. No overlap.
Most names follow a predictable verb_noun pattern (list_counties, list_metro_areas) or noun_noun (zip_crosswalk), but some are noun_verb (fmr_lookup, affordability_check). Slight inconsistency but still clear.
6 tools cover the core functionality of HUD fair market rent and income limit lookups with necessary geocoding support. Each tool earns its place without being excessive.
The set provides comprehensive querying for rent, income, affordability, and geolocation. Missing potential features like historical data or batch operations, but the core use case is well-covered.