Skip to content

Compare two submarkets

A common pattern: the user knows submarkets by name (“Buckhead” vs “Sandy Springs”) but the comparison tool needs IDs. This example chains search_estaite_submarkets to resolve each name, then calls compare_estaite_submarkets with the two IDs.

This is the search → use pattern you’ll write a lot of. Note the short asyncio.sleep between tool calls — the Free tier caps tool calls at 2/sec, and this workflow makes three back-to-back tool calls, so we pace them.

The code

Save as compare-submarkets.py:

"""
Compare two submarkets side by side.
Setup:
pip install mcp
export ESTAITE_API_KEY="mk_..."
Usage:
python compare-submarkets.py "Buckhead" "Sandy Springs"
"""
import argparse
import asyncio
import json
import os
import sys
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
MCP_URL = "https://mcp.estaite.com"
async def resolve_id(session, name: str) -> tuple[int, str]:
"""Search for a submarket by name and return (id, full_name) for the top match."""
result = await session.call_tool(
"search_estaite_submarkets",
{"query": name},
)
if not result.content:
raise RuntimeError(f"Empty response searching for '{name}'")
data = json.loads(result.content[0].text)
matches = data.get("results") or []
if not matches:
raise RuntimeError(f"No submarkets matched '{name}'")
top = matches[0]
return top["id"], top.get("name") or name
async def compare(api_key: str, name_a: str, name_b: str) -> dict:
headers = {"x-api-key": api_key}
async with streamablehttp_client(MCP_URL, headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# Free tier allows 2 tools/sec. We make 3 tool calls back-to-back
# (two searches + one compare), so we pace ~0.6s between them to
# stay under the limit. Production code should also catch 429 and
# honor the Retry-After response header.
id_a, full_a = await resolve_id(session, name_a)
await asyncio.sleep(0.6)
id_b, full_b = await resolve_id(session, name_b)
await asyncio.sleep(0.6)
print(f" Resolved: '{name_a}' → {full_a} (id {id_a})")
print(f" Resolved: '{name_b}' → {full_b} (id {id_b})")
print()
result = await session.call_tool(
"compare_estaite_submarkets",
{"submarkets": [id_a, id_b]},
)
return json.loads(result.content[0].text)
def fmt_money(v): return f"${v:,.0f}" if v is not None else ""
def fmt_pct(v): return f"{v:+.1f}%" if v is not None else ""
def fmt_vac(v): return f"{v:.1f}%" if v is not None else ""
def print_comparison(data: dict, name_a: str, name_b: str) -> None:
subs = data.get("data") or []
if len(subs) < 2:
print(" Comparison failed — fewer than two submarkets returned.")
return
a, b = subs[0], subs[1]
# Summary mode (no property_type/bedrooms specified) returns:
# { segments: { apartments: { br1, br2, br3, br4 }, ... }, market_health: { vacancy, saturation }, ... }
apt2 = lambda s: ((s.get("segments") or {}).get("apartments") or {}).get("br2") or {}
mh = lambda s: s.get("market_health") or {}
rows = [
("Median rent (apt 2BR)", fmt_money(apt2(a).get("median_price")), fmt_money(apt2(b).get("median_price"))),
("YoY change", fmt_pct(apt2(a).get("yoy_change")), fmt_pct(apt2(b).get("yoy_change"))),
("Listings (apt 2BR)", str(apt2(a).get("listings") or ""), str(apt2(b).get("listings") or "")),
("Vacancy", fmt_vac(mh(a).get("vacancy")), fmt_vac(mh(b).get("vacancy"))),
("Saturation", fmt_vac(mh(a).get("saturation")), fmt_vac(mh(b).get("saturation"))),
]
label_a = a.get("submarket_name") or name_a
label_b = b.get("submarket_name") or name_b
print(f" {'Metric':28s} {label_a[:18]:>18s} {label_b[:18]:>18s}")
print(f" {'-' * 28} {'-' * 18} {'-' * 18}")
for label, va, vb in rows:
print(f" {label:28s} {va:>18s} {vb:>18s}")
print()
if data.get("attribution"):
print(f" {data['attribution']}")
print()
def main():
parser = argparse.ArgumentParser(description="Compare two submarkets by name")
parser.add_argument("name_a", help='First submarket (e.g. "Buckhead")')
parser.add_argument("name_b", help='Second submarket (e.g. "Sandy Springs")')
args = parser.parse_args()
api_key = os.environ.get("ESTAITE_API_KEY")
if not api_key:
print("ERROR: ESTAITE_API_KEY env var not set.")
sys.exit(1)
print(f"\nComparing {args.name_a} vs {args.name_b}...\n")
data = asyncio.run(compare(api_key, args.name_a, args.name_b))
print_comparison(data, args.name_a, args.name_b)
if __name__ == "__main__":
main()

Run it

Terminal window
python compare-submarkets.py "Buckhead" "Sandy Springs"

Sample output

Comparing Buckhead vs Sandy Springs...
Resolved: 'Buckhead' → Buckhead (id 758)
Resolved: 'Sandy Springs' → Roswell / Sandy Springs (id 43)
Metric Buckhead Roswell / Sandy Sp
---------------------------- ------------------ ------------------
Median rent (apt 2BR) $2,348 $1,675
YoY change +5.0% -4.3%
Listings (apt 2BR) 276 686
Vacancy 5.5% 5.5%
Saturation 17.6% 10.0%
Data provided by Estaite Solutions (estaite.com)

(Numbers shift each month as the data refreshes — yours may differ.)

What’s happening

  1. Two search_estaite_submarkets calls resolve the user’s friendly names to canonical IDs. Take the top match (results[0]) — that’s almost always what the user meant. Note that partial matches are returned, so “Sandy Springs” can resolve to “Roswell / Sandy Springs” since that’s the most specific submarket containing the name.
  2. One compare_estaite_submarkets call with the IDs returns both submarkets under data in the response, with parallel metrics so the rows line up.
  3. Response shape (summary mode — no property_type/bedrooms): each item has segments.apartments.br2.median_price, .yoy_change, .listings, plus market_health.vacancy and .saturation at the top level. Vacancy and YoY are whole-percent (e.g. 5.5 means 5.5%).
  4. We print a simple text table. In a real app you’d render it as HTML or hand it to an LLM for narration.

Next steps

  • Add a third submarket. compare_estaite_submarkets accepts an array of IDs — pass three or four for a wider view.
  • Filter by property type and bedrooms. Pass property_type: "sfr" and bedrooms: 3 to get the SFR 3BR slice instead of the multi-segment summary. The response shape changes — in slice mode each item has a single metrics object with median_price, yoy_change, listings directly.
  • Disambiguate ambiguous names. Cities have repeated submarket names (every metro has a “Downtown”). When results returns more than one, you can either show the user the list or filter by msa on the search call.