zillow-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Zillow — search listings, fetch property details, Zestimate history, saved searches & homes, market reports. Routes through the user's signed-in zillow.com tab via the fetchproxy browser extension to dodge bot detection.",
10
- "version": "0.2.1"
10
+ "version": "0.3.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Zillow",
16
16
  "source": "./",
17
17
  "description": "MCP server for Zillow — search listings, get property details, Zestimate history, saved searches & homes, market reports",
18
- "version": "0.2.1",
18
+ "version": "0.3.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zillow-mcp",
3
3
  "displayName": "Zillow",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "Zillow real-estate access for Claude — search listings, get property details, Zestimate history, your saved searches & homes, market reports",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -25,6 +25,26 @@ None of them can see what *you* have saved, favorited, or recently viewed — be
25
25
  | `zillow_get_market_report` | Median sale/list/rent, days on market, inventory, ZHVI for a region | |
26
26
  | `zillow_calculate_mortgage` | Local PITI calculator — principal+interest, taxes, insurance, HOA, PMI (no network) | |
27
27
 
28
+ ## Acknowledgement of Terms
29
+
30
+ By using this MCP server, you acknowledge and agree to the following:
31
+
32
+ **1. This server accesses your own Zillow session.** Every request is dispatched through your own browser tab (logged in or not) via the fetchproxy extension. It does not — and cannot — access anyone else's account.
33
+
34
+ **2. [Zillow's Terms of Use](https://www.zillow.com/z/corp/terms/) govern your use of this server**, just as they govern your direct use of zillow.com. The clauses most relevant here:
35
+
36
+ > You may not use any robot, spider, scraper or other automated means to access the Services for any purpose without our express written permission… nor may you conduct automated queries (including screen and database scraping, spiders, robots, crawlers, bypassing CAPTCHAs or similar precautions).
37
+
38
+ You are agreeing to those terms — read by the maintainer 2026-05-23 — every time you invoke a tool in this server. Zillow's terms broadly prohibit automated access without written permission; this is an unofficial tool and Zillow has not granted it permission.
39
+
40
+ **3. Personal, non-commercial use only.** This project is not affiliated with, endorsed by, sponsored by, or in partnership with Zillow Group. It is a personal automation tool that drives the same Zillow website you would drive by hand — one search at a time, your own saved homes, your own market reports. Do not use it to bulk-extract listings, train models, populate a competing real-estate product, or for any commercial purpose.
41
+
42
+ **4. Stability is not guaranteed.** This server reads private internal endpoints (`/async-create-search-page-state/`, `__NEXT_DATA__` blobs, `/myzillow/...`) that Zillow may change without notice. It may break. It may stop working. That's by design — the surface is not theirs to maintain on our behalf.
43
+
44
+ **5. You accept full responsibility** for any consequences of using this server in connection with your Zillow access — rate limiting, account warnings, suspension, IP blocks, captcha walls, or any enforcement action Zillow Group takes. If Zillow objects to your use, stop using this server.
45
+
46
+ This section is the maintainer's good-faith summary of the terms — it is not legal advice and does not modify or supersede Zillow's actual ToS.
47
+
28
48
  ## Install
29
49
 
30
50
  ### Option A — npx (after publishing)
package/dist/bundle.js CHANGED
@@ -36668,6 +36668,40 @@ function textResult(data) {
36668
36668
  };
36669
36669
  }
36670
36670
 
36671
+ // src/next-data.ts
36672
+ var ParseError = class extends Error {
36673
+ constructor(message) {
36674
+ super(message);
36675
+ this.name = "ParseError";
36676
+ }
36677
+ };
36678
+ var OPEN_TAG_RE = /<script[^>]*id=["']__NEXT_DATA__["'][^>]*>/i;
36679
+ var CLOSE_TAG = "</script>";
36680
+ function extractNextData(html) {
36681
+ const openMatch = OPEN_TAG_RE.exec(html);
36682
+ if (!openMatch) {
36683
+ throw new ParseError("__NEXT_DATA__ script tag not found in HTML");
36684
+ }
36685
+ const start = openMatch.index + openMatch[0].length;
36686
+ const end = html.indexOf(CLOSE_TAG, start);
36687
+ if (end < 0) {
36688
+ throw new ParseError("__NEXT_DATA__ script tag has no closing </script>");
36689
+ }
36690
+ const json2 = html.slice(start, end).trim();
36691
+ try {
36692
+ return JSON.parse(json2);
36693
+ } catch (err) {
36694
+ throw new ParseError(
36695
+ `Failed to parse __NEXT_DATA__ JSON: ${err.message}`
36696
+ );
36697
+ }
36698
+ }
36699
+ function getPageProps(nextData) {
36700
+ const props = nextData.props;
36701
+ const pageProps = props?.pageProps;
36702
+ return pageProps ?? {};
36703
+ }
36704
+
36671
36705
  // src/tools/search.ts
36672
36706
  var HOME_TYPE_FILTERS = {
36673
36707
  house: "isSingleFamily",
@@ -36703,7 +36737,7 @@ function formatListing(raw) {
36703
36737
  url: url2
36704
36738
  };
36705
36739
  }
36706
- function buildSearchBody(input) {
36740
+ function buildSearchQueryState(input) {
36707
36741
  const filterState = {};
36708
36742
  switch (input.status ?? "for_sale") {
36709
36743
  case "for_rent":
@@ -36745,17 +36779,18 @@ function buildSearchBody(input) {
36745
36779
  }
36746
36780
  }
36747
36781
  return {
36748
- searchQueryState: {
36749
- usersSearchTerm: input.location,
36750
- filterState,
36751
- isMapVisible: false,
36752
- isListVisible: true
36753
- },
36754
- wants: { cat1: ["listResults"] },
36755
- requestId: 1,
36756
- isDebugRequest: false
36782
+ usersSearchTerm: input.location,
36783
+ filterState,
36784
+ isListVisible: true,
36785
+ isMapVisible: false
36757
36786
  };
36758
36787
  }
36788
+ function buildSearchPath(input) {
36789
+ const sqs = buildSearchQueryState(input);
36790
+ const slug = encodeURIComponent(input.location.trim());
36791
+ const qs = encodeURIComponent(JSON.stringify(sqs));
36792
+ return `/homes/${slug}_rb/?searchQueryState=${qs}`;
36793
+ }
36759
36794
  function registerSearchTools(server2, client2) {
36760
36795
  server2.registerTool(
36761
36796
  "zillow_search_properties",
@@ -36792,9 +36827,12 @@ function registerSearchTools(server2, client2) {
36792
36827
  }
36793
36828
  },
36794
36829
  async (input) => {
36795
- const body = buildSearchBody(input);
36796
- const data = await client2.fetchJson("/async-create-search-page-state/", { method: "POST", body });
36797
- const raw = data.cat1?.searchResults?.listResults ?? [];
36830
+ const path = buildSearchPath(input);
36831
+ const html = await client2.fetchHtml(path);
36832
+ const nextData = extractNextData(html);
36833
+ const pageProps = getPageProps(nextData);
36834
+ const sps = pageProps.searchPageState;
36835
+ const raw = sps?.cat1?.searchResults?.listResults ?? [];
36798
36836
  const limit = input.limit ?? 40;
36799
36837
  const formatted = raw.map(formatListing).filter((x) => x !== null).slice(0, limit);
36800
36838
  return textResult(formatted);
@@ -36802,40 +36840,6 @@ function registerSearchTools(server2, client2) {
36802
36840
  );
36803
36841
  }
36804
36842
 
36805
- // src/next-data.ts
36806
- var ParseError = class extends Error {
36807
- constructor(message) {
36808
- super(message);
36809
- this.name = "ParseError";
36810
- }
36811
- };
36812
- var OPEN_TAG_RE = /<script[^>]*id=["']__NEXT_DATA__["'][^>]*>/i;
36813
- var CLOSE_TAG = "</script>";
36814
- function extractNextData(html) {
36815
- const openMatch = OPEN_TAG_RE.exec(html);
36816
- if (!openMatch) {
36817
- throw new ParseError("__NEXT_DATA__ script tag not found in HTML");
36818
- }
36819
- const start = openMatch.index + openMatch[0].length;
36820
- const end = html.indexOf(CLOSE_TAG, start);
36821
- if (end < 0) {
36822
- throw new ParseError("__NEXT_DATA__ script tag has no closing </script>");
36823
- }
36824
- const json2 = html.slice(start, end).trim();
36825
- try {
36826
- return JSON.parse(json2);
36827
- } catch (err) {
36828
- throw new ParseError(
36829
- `Failed to parse __NEXT_DATA__ JSON: ${err.message}`
36830
- );
36831
- }
36832
- }
36833
- function getPageProps(nextData) {
36834
- const props = nextData.props;
36835
- const pageProps = props?.pageProps;
36836
- return pageProps ?? {};
36837
- }
36838
-
36839
36843
  // src/url.ts
36840
36844
  function urlToPath(input) {
36841
36845
  try {
@@ -36869,7 +36873,20 @@ function findPropertyInPageProps(pageProps) {
36869
36873
  function buildPath(args) {
36870
36874
  if (args.url) return urlToPath(args.url);
36871
36875
  if (args.zpid !== void 0) return `/homedetails/${args.zpid}_zpid/`;
36872
- throw new Error("zillow_get_property: must provide either zpid or url");
36876
+ throw new Error("zillow property tool: must provide either zpid or url");
36877
+ }
36878
+ async function fetchPropertyRecord(client2, args) {
36879
+ const path = buildPath(args);
36880
+ const html = await client2.fetchHtml(path);
36881
+ const nextData = extractNextData(html);
36882
+ const pageProps = getPageProps(nextData);
36883
+ const property = findPropertyInPageProps(pageProps);
36884
+ if (!property) {
36885
+ throw new Error(
36886
+ `Could not locate property data in __NEXT_DATA__ at ${path}. Zillow may have changed their page structure.`
36887
+ );
36888
+ }
36889
+ return { raw: property, path };
36873
36890
  }
36874
36891
  function format(raw) {
36875
36892
  const zpid = String(raw.zpid ?? "");
@@ -36919,17 +36936,8 @@ function registerPropertyTools(server2, client2) {
36919
36936
  }
36920
36937
  },
36921
36938
  async ({ zpid, url: url2 }) => {
36922
- const path = buildPath({ zpid, url: url2 });
36923
- const html = await client2.fetchHtml(path);
36924
- const nextData = extractNextData(html);
36925
- const pageProps = getPageProps(nextData);
36926
- const property = findPropertyInPageProps(pageProps);
36927
- if (!property) {
36928
- throw new Error(
36929
- `Could not locate property data in __NEXT_DATA__ at ${path}. Zillow may have changed their page structure.`
36930
- );
36931
- }
36932
- return textResult(format(property));
36939
+ const { raw } = await fetchPropertyRecord(client2, { zpid, url: url2 });
36940
+ return textResult(format(raw));
36933
36941
  }
36934
36942
  );
36935
36943
  }
@@ -37030,6 +37038,17 @@ function findSavedSearches(pageProps) {
37030
37038
  );
37031
37039
  }
37032
37040
  function findSavedHomes(pageProps) {
37041
+ const collections = pageProps.collectionsResponse;
37042
+ if (Array.isArray(collections)) {
37043
+ const flat = [];
37044
+ for (const c of collections) {
37045
+ if (Array.isArray(c.homes)) flat.push(...c.homes);
37046
+ else if (Array.isArray(c.properties)) flat.push(...c.properties);
37047
+ else if (Array.isArray(c.items)) flat.push(...c.items);
37048
+ }
37049
+ if (flat.length > 0) return flat;
37050
+ if (collections.length > 0) return [];
37051
+ }
37033
37052
  return findArrayByShape(
37034
37053
  pageProps,
37035
37054
  SAVED_HOME_KEYS,
@@ -37080,7 +37099,7 @@ function registerSavedTools(server2, client2) {
37080
37099
  inputSchema: {}
37081
37100
  },
37082
37101
  async () => {
37083
- const html = await client2.fetchHtml("/user/savedSearches/");
37102
+ const html = await client2.fetchHtml("/myzillow/SavedSearches");
37084
37103
  const nextData = extractNextData(html);
37085
37104
  const pageProps = getPageProps(nextData);
37086
37105
  const searches = findSavedSearches(pageProps);
@@ -37091,7 +37110,7 @@ function registerSavedTools(server2, client2) {
37091
37110
  "zillow_get_saved_homes",
37092
37111
  {
37093
37112
  title: "Get my saved (favorited) Zillow homes",
37094
- description: "The signed-in user's saved (favorited) homes on zillow.com. Returns address, price, Zestimate, status, and when each home was saved. Requires the user to be signed in. Read-only; safe to call repeatedly.",
37113
+ description: "The signed-in user's saved (favorited) homes on zillow.com, flattened across all of the user's collections. Returns address, price, Zestimate, status, and when each home was saved. Requires the user to be signed in. Read-only; safe to call repeatedly.",
37095
37114
  annotations: {
37096
37115
  title: "Get my saved (favorited) Zillow homes",
37097
37116
  readOnlyHint: true,
@@ -37101,7 +37120,7 @@ function registerSavedTools(server2, client2) {
37101
37120
  inputSchema: {}
37102
37121
  },
37103
37122
  async () => {
37104
- const html = await client2.fetchHtml("/myzillow/favorites/");
37123
+ const html = await client2.fetchHtml("/myzillow/favorites");
37105
37124
  const nextData = extractNextData(html);
37106
37125
  const pageProps = getPageProps(nextData);
37107
37126
  const homes = findSavedHomes(pageProps);
@@ -37111,35 +37130,34 @@ function registerSavedTools(server2, client2) {
37111
37130
  }
37112
37131
 
37113
37132
  // src/tools/market.ts
37114
- function pickMarketInfo(pageProps) {
37115
- const candidates = [
37116
- pageProps.marketInfo,
37117
- pageProps.marketReport,
37118
- pageProps.regionInfo,
37119
- pageProps.componentProps?.marketInfo
37120
- ];
37133
+ function pickRegion(pageProps) {
37134
+ const candidates = [pageProps.zhviRegion, pageProps.requestedRegion];
37121
37135
  for (const c of candidates) {
37122
37136
  if (c && typeof c === "object") return c;
37123
37137
  }
37124
37138
  return null;
37125
37139
  }
37126
- function format2(raw) {
37140
+ function pickAnalytics(pageProps) {
37141
+ const a = pageProps.odpMarketAnalytics;
37142
+ if (a && typeof a === "object") return a;
37143
+ return null;
37144
+ }
37145
+ function format2(region, analytics) {
37146
+ const yoy = typeof analytics?.zhviLatest?.zhviYoY === "number" ? Math.round(analytics.zhviLatest.zhviYoY * 1e3) / 10 : void 0;
37127
37147
  return {
37128
- region_id: raw.regionId,
37129
- region_name: raw.regionName,
37130
- region_type: raw.regionType,
37131
- median_sale_price: raw.medianSalePrice,
37132
- median_list_price: raw.medianListPrice,
37133
- median_rent_price: raw.medianRentPrice,
37134
- median_days_on_market: raw.medianDaysOnMarket,
37135
- inventory_count: raw.inventoryCount,
37136
- new_listings: raw.newListings,
37137
- pending_sales: raw.pendingSales,
37138
- zhvi: raw.zhvi,
37139
- zhvi_yoy_percent: raw.zhviYoYPercent,
37140
- for_sale_by_category: raw.forSaleByCategory,
37141
- buyer_seller_index: raw.buyerSellerIndex,
37142
- as_of_date: raw.asOfDate
37148
+ region_name: region?.name,
37149
+ region_type: region?.regionTypeName,
37150
+ parent_county: region?.parentCounty?.name,
37151
+ parent_state: region?.parentState?.name,
37152
+ median_sale_price: analytics?.mrktSaleLatest?.medianSalePrice,
37153
+ median_list_price: analytics?.mrktListingLatest?.medianListPrice,
37154
+ median_days_on_market: analytics?.mrktListingLatest?.medianDaysOnMarket,
37155
+ days_to_pending: analytics?.mrktSaleLatest?.daysToPending,
37156
+ new_listings: analytics?.mrktListingLatest?.newListings,
37157
+ for_sale_inventory: analytics?.mrktListingLatest?.forSaleInventory,
37158
+ zhvi: analytics?.zhviLatest?.zhvi,
37159
+ zhvi_yoy_percent: yoy,
37160
+ as_of_date: analytics?.zhviLatest?.asOfDate
37143
37161
  };
37144
37162
  }
37145
37163
  function pathFromInput(args) {
@@ -37157,7 +37175,7 @@ function registerMarketTools(server2, client2) {
37157
37175
  "zillow_get_market_report",
37158
37176
  {
37159
37177
  title: "Get Zillow market report for a region",
37160
- description: 'Market report for a Zillow region: median sale/list/rent prices, days on market, inventory, Zillow Home Value Index (ZHVI), year-over-year ZHVI change, and buyer/seller balance. Provide either a `region_path` (e.g. "/home-values/6181/brooklyn-ny/") or a full Zillow home-values URL. Read-only; safe to call repeatedly.',
37178
+ description: 'Market report for a Zillow region: median sale/list prices, days on market, for-sale inventory, new listings, Zillow Home Value Index (ZHVI), and year-over-year ZHVI change. Provide either a `region_path` (e.g. "/home-values/6181/brooklyn-ny/") or a full Zillow home-values URL. Read-only; safe to call repeatedly.',
37161
37179
  annotations: {
37162
37180
  title: "Get Zillow market report for a region",
37163
37181
  readOnlyHint: true,
@@ -37176,13 +37194,14 @@ function registerMarketTools(server2, client2) {
37176
37194
  const html = await client2.fetchHtml(path);
37177
37195
  const nextData = extractNextData(html);
37178
37196
  const pageProps = getPageProps(nextData);
37179
- const market = pickMarketInfo(pageProps);
37180
- if (!market) {
37197
+ const region = pickRegion(pageProps);
37198
+ const analytics = pickAnalytics(pageProps);
37199
+ if (!region && !analytics) {
37181
37200
  throw new Error(
37182
- `Could not locate marketInfo in __NEXT_DATA__ at ${path}.`
37201
+ `Could not locate market data (zhviRegion + odpMarketAnalytics) in __NEXT_DATA__ at ${path}.`
37183
37202
  );
37184
37203
  }
37185
- return textResult(format2(market));
37204
+ return textResult(format2(region, analytics));
37186
37205
  }
37187
37206
  );
37188
37207
  }
@@ -37265,8 +37284,338 @@ function registerMortgageTools(server2) {
37265
37284
  );
37266
37285
  }
37267
37286
 
37287
+ // src/tools/history.ts
37288
+ function toPercent(rate) {
37289
+ if (typeof rate !== "number") return void 0;
37290
+ return Math.round(rate * 1e3) / 10;
37291
+ }
37292
+ function formatPriceEvent(raw) {
37293
+ const date5 = raw.date ?? (typeof raw.time === "number" ? new Date(raw.time).toISOString().slice(0, 10) : void 0);
37294
+ return {
37295
+ date: date5,
37296
+ event: raw.event,
37297
+ price: raw.price,
37298
+ price_change_percent: toPercent(raw.priceChangeRate),
37299
+ price_per_sqft: raw.pricePerSquareFoot,
37300
+ source: raw.source,
37301
+ mls_number: raw.attributeSource?.infoString1
37302
+ };
37303
+ }
37304
+ function formatTaxEvent(raw) {
37305
+ const year = typeof raw.time === "number" ? new Date(raw.time).getUTCFullYear() : void 0;
37306
+ return {
37307
+ year,
37308
+ tax_paid: raw.taxPaid,
37309
+ tax_increase_percent: toPercent(raw.taxIncreaseRate),
37310
+ assessed_value: raw.value,
37311
+ assessed_value_increase_percent: toPercent(raw.valueIncreaseRate)
37312
+ };
37313
+ }
37314
+ function registerHistoryTools(server2, client2) {
37315
+ server2.registerTool(
37316
+ "zillow_get_price_history",
37317
+ {
37318
+ title: "Get Zillow price history for a property",
37319
+ description: "Listing-price events for a property \u2014 listings, price changes, pending, sold, etc. \u2014 by zpid or homedetails URL. Each entry has a date, event type, price, percent price-change, price/sqft, and MLS attribution. Sourced from the same homedetails page as zillow_get_property, but returns just the price-history series for easier downstream reasoning.",
37320
+ annotations: {
37321
+ title: "Get Zillow price history for a property",
37322
+ readOnlyHint: true,
37323
+ idempotentHint: true,
37324
+ openWorldHint: true
37325
+ },
37326
+ inputSchema: {
37327
+ zpid: external_exports.union([external_exports.number().int().positive(), external_exports.string()]).optional().describe("Zillow Property ID"),
37328
+ url: external_exports.string().optional().describe("Zillow homedetails URL or path")
37329
+ }
37330
+ },
37331
+ async ({ zpid, url: url2 }) => {
37332
+ const { raw } = await fetchPropertyRecord(client2, { zpid, url: url2 });
37333
+ const events = (raw.priceHistory ?? []).map(formatPriceEvent);
37334
+ return textResult({ zpid: String(raw.zpid ?? zpid ?? ""), events });
37335
+ }
37336
+ );
37337
+ server2.registerTool(
37338
+ "zillow_get_tax_history",
37339
+ {
37340
+ title: "Get Zillow tax history for a property",
37341
+ description: "Year-by-year property-tax record for a property: tax paid, assessed value, and the year-over-year change rates. Sourced from the homedetails page. Useful for spotting reassessment jumps or comparing tax burdens across properties.",
37342
+ annotations: {
37343
+ title: "Get Zillow tax history for a property",
37344
+ readOnlyHint: true,
37345
+ idempotentHint: true,
37346
+ openWorldHint: true
37347
+ },
37348
+ inputSchema: {
37349
+ zpid: external_exports.union([external_exports.number().int().positive(), external_exports.string()]).optional().describe("Zillow Property ID"),
37350
+ url: external_exports.string().optional().describe("Zillow homedetails URL or path")
37351
+ }
37352
+ },
37353
+ async ({ zpid, url: url2 }) => {
37354
+ const { raw } = await fetchPropertyRecord(client2, { zpid, url: url2 });
37355
+ const events = (raw.taxHistory ?? []).map(formatTaxEvent);
37356
+ return textResult({ zpid: String(raw.zpid ?? zpid ?? ""), events });
37357
+ }
37358
+ );
37359
+ }
37360
+
37361
+ // src/tools/compare.ts
37362
+ function buildSummary(rows) {
37363
+ const pick2 = (label, fn) => ({
37364
+ field: label,
37365
+ values: rows.map((r) => r.property ? fn(r.property) ?? null : null)
37366
+ });
37367
+ return [
37368
+ pick2("price", (p) => p.price),
37369
+ pick2("zestimate", (p) => p.zestimate),
37370
+ pick2("rent_zestimate", (p) => p.rent_zestimate),
37371
+ pick2("beds", (p) => p.beds),
37372
+ pick2("baths", (p) => p.baths),
37373
+ pick2("living_area_sqft", (p) => p.living_area),
37374
+ pick2("lot_size_sqft", (p) => p.lot_size),
37375
+ pick2("year_built", (p) => p.year_built),
37376
+ pick2("home_type", (p) => p.home_type),
37377
+ pick2("status", (p) => p.status),
37378
+ pick2("days_on_zillow", (p) => p.days_on_zillow),
37379
+ pick2("tax_assessed_value", (p) => p.tax_assessed_value),
37380
+ pick2("neighborhood", (p) => p.neighborhood)
37381
+ ];
37382
+ }
37383
+ function registerCompareTools(server2, client2) {
37384
+ server2.registerTool(
37385
+ "zillow_compare_properties",
37386
+ {
37387
+ title: "Compare multiple Zillow properties side-by-side",
37388
+ description: "Fetch and compare 2 or more Zillow properties side-by-side. Provide an array of zpids (or homedetails URLs). Returns a compact summary table aligned by field (price, beds/baths, sqft, year built, Zestimate, etc.) plus the full per-property record. Errors for individual properties are captured per-row \u2014 one bad zpid won't fail the whole call. Calls are concurrent.",
37389
+ annotations: {
37390
+ title: "Compare multiple Zillow properties side-by-side",
37391
+ readOnlyHint: true,
37392
+ idempotentHint: true,
37393
+ openWorldHint: true
37394
+ },
37395
+ inputSchema: {
37396
+ zpids: external_exports.array(external_exports.union([external_exports.number().int().positive(), external_exports.string()])).min(2).max(8).optional().describe(
37397
+ "Array of 2\u20138 zpids to compare. Provide either zpids or urls."
37398
+ ),
37399
+ urls: external_exports.array(external_exports.string()).min(2).max(8).optional().describe(
37400
+ "Array of 2\u20138 Zillow homedetails URLs/paths to compare. Provide either zpids or urls."
37401
+ )
37402
+ }
37403
+ },
37404
+ async ({ zpids, urls }) => {
37405
+ const targets = zpids && zpids.length > 0 ? zpids.map((zpid) => ({ zpid })) : urls && urls.length > 0 ? urls.map((url2) => ({ url: url2 })) : null;
37406
+ if (!targets || targets.length < 2) {
37407
+ throw new Error(
37408
+ "zillow_compare_properties: provide an array of at least 2 zpids or urls."
37409
+ );
37410
+ }
37411
+ const results = await Promise.all(
37412
+ targets.map(async (t) => {
37413
+ const fallbackZpid = "zpid" in t ? String(t.zpid) : "";
37414
+ try {
37415
+ const { raw } = await fetchPropertyRecord(client2, t);
37416
+ return {
37417
+ zpid: String(raw.zpid ?? fallbackZpid),
37418
+ property: format(raw)
37419
+ };
37420
+ } catch (e) {
37421
+ return { zpid: fallbackZpid, error: e.message };
37422
+ }
37423
+ })
37424
+ );
37425
+ return textResult({
37426
+ count: results.length,
37427
+ summary: buildSummary(results),
37428
+ results
37429
+ });
37430
+ }
37431
+ );
37432
+ }
37433
+
37434
+ // src/tools/affordability.ts
37435
+ function computeAffordability(input) {
37436
+ if (input.monthly_income <= 0)
37437
+ throw new Error("monthly_income must be positive");
37438
+ if (input.down_payment < 0) throw new Error("down_payment must be >= 0");
37439
+ if (input.interest_rate < 0)
37440
+ throw new Error("interest_rate must be >= 0");
37441
+ const term_years = input.loan_term_years ?? 30;
37442
+ const monthly_debts = input.monthly_debts ?? 0;
37443
+ const front_dti = input.front_end_dti ?? 0.28;
37444
+ const back_dti = input.back_end_dti ?? 0.36;
37445
+ const tax_rate = input.property_tax_rate ?? 1.1;
37446
+ const insurance_annual = input.insurance_annual ?? 0;
37447
+ const hoa_monthly = input.hoa_monthly ?? 0;
37448
+ const front_max = input.monthly_income * front_dti;
37449
+ const back_max = input.monthly_income * back_dti - monthly_debts;
37450
+ const max_piti = Math.max(0, Math.min(front_max, back_max));
37451
+ const binding = front_max <= back_max ? "front_end" : "back_end";
37452
+ const monthly_ins = insurance_annual / 12;
37453
+ const monthly_tax_per_dollar = tax_rate / 100 / 12;
37454
+ const monthly_pi_budget = Math.max(
37455
+ 0,
37456
+ max_piti - monthly_ins - hoa_monthly
37457
+ );
37458
+ const r = input.interest_rate / 100 / 12;
37459
+ const n = term_years * 12;
37460
+ const factor = r === 0 ? 1 / n : r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1);
37461
+ const coeff = monthly_tax_per_dollar + factor;
37462
+ const max_home_price = coeff === 0 ? input.down_payment : (monthly_pi_budget + input.down_payment * factor) / coeff;
37463
+ const loan_amount = Math.max(0, max_home_price - input.down_payment);
37464
+ const monthly_pi = r === 0 ? loan_amount / n : loan_amount * factor;
37465
+ const monthly_tax = max_home_price * monthly_tax_per_dollar;
37466
+ return {
37467
+ max_home_price: round22(max_home_price),
37468
+ max_monthly_piti: round22(max_piti),
37469
+ binding_constraint: binding,
37470
+ monthly_principal_interest: round22(monthly_pi),
37471
+ monthly_property_tax: round22(monthly_tax),
37472
+ monthly_insurance: round22(monthly_ins),
37473
+ monthly_hoa: round22(hoa_monthly),
37474
+ loan_amount: round22(loan_amount),
37475
+ down_payment: round22(input.down_payment),
37476
+ front_end_dti_used: front_dti,
37477
+ back_end_dti_used: back_dti
37478
+ };
37479
+ }
37480
+ function computeRentVsBuy(input) {
37481
+ if (input.home_price <= 0) throw new Error("home_price must be positive");
37482
+ if (input.monthly_rent <= 0)
37483
+ throw new Error("monthly_rent must be positive");
37484
+ const horizon = input.horizon_years ?? 7;
37485
+ if (horizon <= 0) throw new Error("horizon_years must be positive");
37486
+ const term_years = input.loan_term_years ?? 30;
37487
+ const r = input.interest_rate / 100 / 12;
37488
+ const n = term_years * 12;
37489
+ const loan = Math.max(0, input.home_price - input.down_payment);
37490
+ const monthly_pi = r === 0 ? loan / n : loan * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
37491
+ const closing = input.home_price * ((input.closing_cost_rate ?? 2.5) / 100);
37492
+ const selling_rate = (input.selling_cost_rate ?? 6) / 100;
37493
+ const tax_rate = (input.property_tax_rate ?? 1.1) / 100;
37494
+ const insurance = input.insurance_annual ?? 0;
37495
+ const hoa = (input.hoa_monthly ?? 0) * 12;
37496
+ const maint_rate = (input.maintenance_rate ?? 1) / 100;
37497
+ const appreciation = (input.appreciation_rate ?? 3) / 100;
37498
+ const rent_growth = (input.rent_growth_rate ?? 3) / 100;
37499
+ const invest_return = (input.investment_return_rate ?? 6) / 100;
37500
+ let cum_buy = input.down_payment + closing;
37501
+ let cum_rent = 0;
37502
+ let invested = input.down_payment;
37503
+ let home_value = input.home_price;
37504
+ let principal_remaining = loan;
37505
+ const yearly = [];
37506
+ let break_even = null;
37507
+ let final_net_after_sale = 0;
37508
+ for (let y = 1; y <= horizon; y++) {
37509
+ const year_pi = monthly_pi * 12;
37510
+ const year_tax = home_value * tax_rate;
37511
+ const year_maint = home_value * maint_rate;
37512
+ cum_buy += year_pi + year_tax + insurance + hoa + year_maint;
37513
+ let interest_paid = 0;
37514
+ let principal_paid = 0;
37515
+ for (let m = 0; m < 12; m++) {
37516
+ const int_m = principal_remaining * r;
37517
+ const pi_m = Math.min(monthly_pi, principal_remaining + int_m);
37518
+ const prin_m = pi_m - int_m;
37519
+ principal_remaining = Math.max(0, principal_remaining - prin_m);
37520
+ interest_paid += int_m;
37521
+ principal_paid += prin_m;
37522
+ }
37523
+ const year_rent = input.monthly_rent * 12 * Math.pow(1 + rent_growth, y - 1);
37524
+ cum_rent += year_rent;
37525
+ invested = invested * (1 + invest_return);
37526
+ home_value = home_value * (1 + appreciation);
37527
+ const sale_proceeds = home_value * (1 - selling_rate) - principal_remaining;
37528
+ const buy_net_if_sold = cum_buy - sale_proceeds;
37529
+ const renter_invest_growth = invested - input.down_payment;
37530
+ const rent_net = cum_rent - renter_invest_growth;
37531
+ if (break_even === null && buy_net_if_sold <= rent_net) break_even = y;
37532
+ yearly.push({
37533
+ year: y,
37534
+ cumulative_buy_cost: round22(cum_buy),
37535
+ cumulative_rent_cost: round22(cum_rent),
37536
+ home_value: round22(home_value),
37537
+ remaining_mortgage: round22(principal_remaining),
37538
+ equity_if_sold_now: round22(home_value * (1 - selling_rate) - principal_remaining)
37539
+ });
37540
+ if (y === horizon) final_net_after_sale = buy_net_if_sold - rent_net;
37541
+ }
37542
+ return {
37543
+ horizon_years: horizon,
37544
+ buy_total_cost_after_sale: round22(
37545
+ cum_buy - (home_value * (1 - selling_rate) - principal_remaining)
37546
+ ),
37547
+ rent_total_cost: round22(cum_rent - (invested - input.down_payment)),
37548
+ net_difference: round22(final_net_after_sale),
37549
+ buy_wins: final_net_after_sale < 0,
37550
+ break_even_year: break_even,
37551
+ yearly
37552
+ };
37553
+ }
37554
+ function round22(n) {
37555
+ return Math.round(n * 100) / 100;
37556
+ }
37557
+ function registerAffordabilityTools(server2) {
37558
+ server2.registerTool(
37559
+ "zillow_calculate_affordability",
37560
+ {
37561
+ title: "Calculate max affordable home price",
37562
+ description: "Solve for the maximum home price you can afford under the standard 28/36 DTI rule. Inputs: monthly income, monthly recurring debts (car loans, student loans, etc.), down payment, interest rate, and optional property-tax rate / insurance / HOA / loan term. Output: max home price, the binding constraint (front-end vs back-end), and the full PITI breakdown at that price. No network \u2014 pure local math.",
37563
+ annotations: {
37564
+ title: "Calculate max affordable home price",
37565
+ readOnlyHint: true,
37566
+ idempotentHint: true,
37567
+ openWorldHint: false
37568
+ },
37569
+ inputSchema: {
37570
+ monthly_income: external_exports.number().positive(),
37571
+ monthly_debts: external_exports.number().nonnegative().optional().describe("Sum of monthly debt payments (car, student loans, etc.)"),
37572
+ down_payment: external_exports.number().nonnegative(),
37573
+ interest_rate: external_exports.number().nonnegative().describe("Annual %, e.g. 6.5"),
37574
+ loan_term_years: external_exports.number().int().positive().optional().describe("Default 30"),
37575
+ property_tax_rate: external_exports.number().nonnegative().optional().describe("Annual % of home price, default 1.1"),
37576
+ insurance_annual: external_exports.number().nonnegative().optional(),
37577
+ hoa_monthly: external_exports.number().nonnegative().optional(),
37578
+ front_end_dti: external_exports.number().positive().max(1).optional().describe("Front-end DTI cap as decimal, default 0.28"),
37579
+ back_end_dti: external_exports.number().positive().max(1).optional().describe("Back-end DTI cap as decimal, default 0.36")
37580
+ }
37581
+ },
37582
+ async (input) => textResult(computeAffordability(input))
37583
+ );
37584
+ server2.registerTool(
37585
+ "zillow_estimate_rent_vs_buy",
37586
+ {
37587
+ title: "Estimate rent-vs-buy break-even over a horizon",
37588
+ description: "Project the cumulative cost of buying a home versus renting a comparable place over N years. Accounts for down payment, closing costs, monthly PITI, maintenance (~1%/yr default), property appreciation (~3%/yr default), rent growth (~3%/yr default), and the opportunity cost of the down payment (renter invests it at the investment_return_rate, default 6%/yr). Returns the year-by-year cumulative costs, the break-even year, and the net difference at the horizon. No network \u2014 pure local math.",
37589
+ annotations: {
37590
+ title: "Estimate rent-vs-buy break-even over a horizon",
37591
+ readOnlyHint: true,
37592
+ idempotentHint: true,
37593
+ openWorldHint: false
37594
+ },
37595
+ inputSchema: {
37596
+ home_price: external_exports.number().positive(),
37597
+ down_payment: external_exports.number().nonnegative(),
37598
+ interest_rate: external_exports.number().nonnegative(),
37599
+ loan_term_years: external_exports.number().int().positive().optional(),
37600
+ property_tax_rate: external_exports.number().nonnegative().optional(),
37601
+ insurance_annual: external_exports.number().nonnegative().optional(),
37602
+ hoa_monthly: external_exports.number().nonnegative().optional(),
37603
+ maintenance_rate: external_exports.number().nonnegative().optional().describe("Annual % of home value, default 1.0"),
37604
+ closing_cost_rate: external_exports.number().nonnegative().optional().describe("% of home price, default 2.5"),
37605
+ selling_cost_rate: external_exports.number().nonnegative().optional().describe("% of sale price, default 6.0"),
37606
+ appreciation_rate: external_exports.number().optional().describe("Annual %, default 3.0"),
37607
+ monthly_rent: external_exports.number().positive(),
37608
+ rent_growth_rate: external_exports.number().optional().describe("Annual %, default 3.0"),
37609
+ investment_return_rate: external_exports.number().optional().describe("Annual return on the renter's parallel-invested down payment, default 6.0"),
37610
+ horizon_years: external_exports.number().int().positive().optional().describe("Default 7")
37611
+ }
37612
+ },
37613
+ async (input) => textResult(computeRentVsBuy(input))
37614
+ );
37615
+ }
37616
+
37268
37617
  // src/index.ts
37269
- var VERSION = "0.2.1";
37618
+ var VERSION = "0.3.0";
37270
37619
  var port = process.env.ZILLOW_WS_PORT ? Number(process.env.ZILLOW_WS_PORT) : void 0;
37271
37620
  var transport = new FetchproxyTransport({ port, version: VERSION });
37272
37621
  var client = new ZillowClient({ transport });
@@ -37278,6 +37627,9 @@ registerZestimateTools(server, client);
37278
37627
  registerSavedTools(server, client);
37279
37628
  registerMarketTools(server, client);
37280
37629
  registerMortgageTools(server);
37630
+ registerHistoryTools(server, client);
37631
+ registerCompareTools(server, client);
37632
+ registerAffordabilityTools(server);
37281
37633
  console.error(
37282
37634
  `[zillow-mcp] v${VERSION} \u2014 WebSocket bridge via @fetchproxy/server on 127.0.0.1:${port ?? 37149}. Install the fetchproxy extension (see https://github.com/chrischall/fetchproxy) and sign into zillow.com. This project was developed and is maintained by AI (Claude). Use at your own discretion.`
37283
37635
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zillow-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "mcpName": "io.github.chrischall/zillow-mcp",
5
5
  "description": "Zillow MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/zillow-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.1",
9
+ "version": "0.3.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "zillow-mcp",
14
- "version": "0.2.1",
14
+ "version": "0.3.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },