Skip to main content
Glama
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