zillow-mcp
by titusblair
README.md
# zillow-mcp
An unofficial, experimental MCP (Model Context Protocol) server that lets a
model search Zillow listings.
> **Unofficial and experimental.** Not made by, affiliated with, or endorsed
> by Zillow in any way. It works by reading Zillow's own public search-result
> pages, not a real API, so it can stop working at any time. See
> [Known limitations](#known-limitations) before you rely on it for anything.
No API key -- Zillow does not offer a public listings API. This works by
fetching Zillow's own search-results pages the way a browser would, and
reading the listing data out of the HTML. That is also exactly why it can
break: see [Known limitations](#known-limitations) before you rely on it
for anything.
```
"3+ bed, 2+ bath rentals in Austin, TX, newest first"
search_homes(location="Austin, TX", status="rent",
min_beds=3, min_baths=2, sort="newest")
-> resolved_location: "Austin TX"
41 listings
[{ address: "100 Example St, Austin, TX 78701", price: 2450,
beds: 3, baths: 2, sqft: 1650, zpid: "10000001",
url: "https://www.zillow.com/homedetails/...", days_on_zillow: 4 }, ...]
```
---
## Why this exists
Zillow has no public API for listing search. What it has is a
server-rendered search page: type a location into zillow.com, and the
HTML that comes back has the full result set embedded as JSON, no
follow-up API call needed. This project fetches that page directly with a
plain HTTP request and reads the JSON back out.
That is the entire mechanism, and it is worth being direct about what it
is not: it is not an API integration, it is not guaranteed to keep
working, and it is not affiliated with Zillow in any way. It is a
best-effort, reverse-engineered method, and the design here is built
around being honest about that rather than hiding it:
**One tool.** `search_homes`. No location-geocoding tool bolted on the
front, no separate rent/sale/sold tools -- `location` is plain text and
`status` is a plain parameter, resolved internally.
**Accept what a model actually has.** `location` takes a city and state
("Austin, TX"), a ZIP code ("78704"), or a neighborhood -- whatever text a
model would naturally reach for. No pre-resolved coordinates, no Zillow
region ID.
**Never return a bot-check page as if it were listings.** This is the one
that matters most for a scraper. Every failure mode is caught explicitly
and never silently parsed as a normal result:
- Zillow's bot-check challenge page (PerimeterX) -- detected by signature,
not treated as an empty result set.
- A location Zillow can't place, which -- confirmed by testing -- doesn't
error, it silently falls back to *some* default region and returns a
normal-looking page full of real listings for the wrong place. Every
result is checked against what was actually asked for before being
returned; see [The two problems worth reading the code for](#the-two-problems-worth-reading-the-code-for).
- A location specific enough to redirect to one property's page instead
of a search-results page.
**Errors are instructions.** Every failure says what to do next: retry in
a bit, use a more specific location, widen the filters. None of them just
say "failed."
---
## Install
```bash
pip install -e .
```
**Claude Code:**
```bash
claude mcp add zillow -- zillow-mcp
```
**Claude Desktop** -- in `claude_desktop_config.json`:
```json
{
"mcpServers": {
"zillow": {
"command": "zillow-mcp"
}
}
}
```
No environment variables, no API key -- there's nothing to configure.
---
## Tool
| Tool | What it does |
|---|---|
| `search_homes` | Search an area for listings, for rent / for sale / recently sold. |
**Parameters:** `location` (required -- city/state, ZIP, or neighborhood),
`status` (`rent` / `sale` / `sold`, default `rent`), `min_price`,
`max_price`, `min_beds`, `max_beds`, `min_baths`, `max_baths`, `sort`
(`relevant` / `newest` / `price_asc` / `price_desc` / `beds` / `baths` /
`sqft` / `lot_size`), `page`.
Search only. There is no single-property-detail tool in v1 -- if a
`location` is specific enough that Zillow resolves it to one address, the
tool refuses with an explanation rather than guessing at what you wanted.
---
## The two problems worth reading the code for
### A silent wrong location is worse than an error
Feed Zillow's search endpoint a location it doesn't recognize and it does
not return an error page. It falls back to *some* default region --
during testing, that was consistently Saint George, UT, for a machine
whose actual location is nowhere nearby -- and serves back a completely
normal-looking results page, 41 real listings, no error text anywhere. A
naive integration would return those listings as if they answered the
question asked.
`zillow_mcp/location.py`'s `resolution_matches` exists entirely to catch
this: after the page comes back, the region Zillow actually resolved
(from the page's own `regionState`) is checked against what was typed,
using token overlap for city/state text and substring matching for ZIP
codes. A mismatch raises an error naming both the requested location and
what Zillow actually returned, rather than handing back results for the
wrong city with a straight face.
### Telling a bot-check page from a real one, cheaply
Zillow runs bot detection (PerimeterX) in front of these pages. A plain
`httpx` GET has none of the signals a browser has, so it gets challenged
sometimes -- during development, requests moved from succeeding
consistently to being blocked consistently within the same short testing
session, which suggests the block is tied to request-pattern/session
reputation, not just IP identity. `zillow_mcp/client.py` checks every
response for PerimeterX's signature text (`px-captcha`, `perimeterx`,
"access to this page has been denied") and for a 403/429 status,
regardless of whether the HTTP status looks successful -- a block can come
back as an ordinary 200 with a challenge document as the body. A match
raises an error that says plainly that this is a bot check, that this
method is unofficial and expected to fail sometimes, and suggests
retrying later or spacing out requests -- never an attempt to parse the
challenge page as if it contained listings.
---
## What actually works right now (tested live, September 2026)
- **Plain-text location works with no geocoding step.** A bare ZIP code
or a `city-state` slug placed directly in the URL path
(`/homes/for_rent/austin-tx/`, `/homes/for_rent/78704/`) resolves the
region server-side. No bounding-box math, no separate geocoding
request -- confirmed against live Zillow search URLs.
- **When a request gets through, the data is real and complete.** Address,
price, beds, baths, sqft, zpid, listing URL, days on Zillow, lat/lng --
all present in the embedded `__NEXT_DATA__` JSON on a successful fetch.
- **Bot-blocking is real and inconsistent.** In testing, the first several
requests in a session succeeded cleanly (HTTP 200, full listing data,
correct region). After a short burst of requests, every subsequent
request in that session was blocked with a PerimeterX challenge page,
even for URLs that had succeeded minutes earlier. A single request after
a pause succeeded again. **Practical read: this works well for occasional,
spaced-out queries and is not reliable for back-to-back or high-volume
use.** There is no retry/backoff logic built in on purpose -- see below.
---
## Known limitations
- **Unofficial and reverse-engineered.** This is not a Zillow product,
has no affiliation with Zillow, and uses no official API. It works by
reading Zillow's own public search-results HTML.
- **Can break at any time.** Zillow can change its page structure, its
embedded-JSON format, or tighten its bot detection with no notice, and
any of those can silently stop this from working. `zillow_mcp/parser.py`
documents exactly where in the JSON it expects the listings to be, so a
break is a small, findable diff rather than a mystery.
- **Gets blocked, and that's treated as normal, not a bug.** No headless
browser, no proxy rotation, no CAPTCHA solving -- this is a plain HTTP
request on purpose (see [Constraints](#why-a-plain-http-request) below).
When Zillow's bot detection catches it, the tool says so clearly instead
of failing silently or returning something that looks like real data.
- **Not for high-volume use.** No caching, no request queue, no retry
logic. Space requests out. Hammering it will get the requesting IP
blocked faster, not return more data.
- **Data may be stale or inaccurate.** This reads whatever Zillow's page
currently shows; it is not a live feed and carries no guarantee of
freshness or correctness. Verify anything that matters on zillow.com
directly.
- **Location resolution is a heuristic, not a geocoder.** The
wrong-location check (`resolution_matches`) catches clearly mismatched
regions using word overlap, not authoritative geocoding. It can, in
principle, be fooled by a real-but-coincidental word match, or reject a
legitimate location it doesn't recognize the phrasing of.
- **No single-property lookup.** Search only, by design (see
[Scope](#tool) above). An address-like location fails with an
explanation instead of returning that one property.
### Why a plain HTTP request
A headless browser or a paid scraping/proxy service would almost
certainly get through more often. Both were deliberately left out of v1:
a headless browser is a much heavier dependency and runtime cost for a
tool meant to be simple to install, and a paid proxy service means a
second thing to sign up for and pay for just to try this out. The
trade-off is accepted on purpose -- this fails openly and instructively
when blocked rather than pretending to be more reliable than it is.
---
## Tests
```bash
python3 tests/test_url.py
python3 tests/test_parser.py
python3 tests/test_protocol.py
```
`test_url.py` and `test_parser.py` run offline -- no network, nothing that
can break because Zillow changed something or is blocking requests right
now. `test_parser.py` runs against a saved local HTML fixture
(`tests/fixtures/search_results.html`) rather than a live page; that
fixture is a hand-built stand-in matching the real `__NEXT_DATA__` shape
confirmed by inspecting live Zillow pages, not a captured copy of an
actual page, so no real scraped listing data ships in this repo.
`test_protocol.py` launches the server as a subprocess and talks to it
over the real MCP stdio protocol -- the only live-feeling part of that
test is a deliberately bad parameter, which fails validation before any
network request, so it stays deterministic.
## Evals
```bash
python3 evals/run_eval.py
```
Runs `search_homes` against a handful of real Zillow queries and reports
each as succeeded, cleanly blocked, or -- the only real failure category
-- unexpected (a crash, malformed output, or a wrong-location bug slipping
through). A blocked result is not a failing eval; a silent wrong answer
is. It also runs one negative case (a specific street address) to confirm
the single-property-page guard rail actually fires instead of quietly
returning that one home.
---
## Not affiliated with Zillow
Zillow is a trademark of Zillow, Inc. This project is not produced,
endorsed, or supported by Zillow in any way.
## License
MIT
TDQS
A4.1/5.0
Scored across 1 tool
Disambiguation5/5
With only a single tool, there is no possibility of confusing it with another. The tool's purpose is clearly defined and self-contained.
Naming Consistency5/5
The one tool name follows a conventional verb_noun pattern (search_homes) and there are no conflicting conventions to create inconsistency.
Tool Count3/5
A single tool feels thin for a server named after a major real estate platform, but the tool is richly parameterized and may be intentionally scoped to search only. It falls into the borderline area for count.
Completeness3/5
Home search is covered thoroughly with many filters, but the server lacks property-detail lookup by Zillow ID, address-specific searches, or history/listing-related tools. Agents can find homes but cannot go deeper without leaving the MCP.
Maintenance
ActivityMaintained
ResponsivenessNo issues