forsale-mcp
README.md
# 4Sale MCP Server (`forsale-mcp`)
An extensive, **read‑only** [Model Context Protocol](https://modelcontextprotocol.io)
server that exposes **Kuwait's 4Sale marketplace** (`q84sale.com`) to LLM agents
through **62 well‑described tools** — search, listings, sellers & reputation,
taxonomy, attributes, **location & commute intelligence**, **attribute‑aware car
search**, market intelligence, fraud & negotiation helpers, promotions,
server‑driven UI, diagnostics, and (optionally) authenticated account endpoints —
plus a standalone **price‑drop watcher** (`watch.py`).
It is a much larger sibling of `opensooq-mcp`, built by reverse‑engineering
4Sale's private web API from a captured browser session. The request‑signing
scheme, the full category taxonomy and every endpoint below were derived from
that capture and verified against the live API (the signer matched **71/71**
captured requests and returns live data; the live diagnostic passes 15/15).
---
## ⚠️ Responsible use (please read)
**4Sale (q84sale.com) is a real, live website.** This project is an unofficial,
educational, **read‑only** API client intended for personal research and
interoperability — it issues the same public calls a browser makes and **never**
posts, edits, deletes, logs in on your behalf, or unmasks private data.
- Respect 4Sale's Terms of Service and Kuwaiti law.
- **Do not scrape at scale.** The client deliberately paginates modestly, retries
with backoff, and enters a local cooldown when throttled. Keep volume low.
- Authenticated tools require **your own** token; nothing here bypasses auth.
- The API enforces anti‑abuse rate limiting (HTTP 403/429). If you hit it, the
tools tell you to back off — please do.
---
## What it's for — real questions, *with* vs *without* the MCP
Below are genuine things a 4Sale user in Kuwait would ask. For each, the first
answer is what a normal assistant says with **no** marketplace access; the second
is what it can do **with** this MCP. (Figures in the "with" answers are pulled
live and will vary — they're shown to illustrate the shape of the result.)
### 1. "I'm selling my 2019 Toyota Camry — what should I list it for?"
**Without the MCP:**
> *I can't see live 4Sale prices. As a rough guide a 2019 Camry in Kuwait might be somewhere around 3,000–4,500 KWD depending on mileage and trim — but please check q84sale.com for current listings to be sure.*
A vague, possibly stale band with a disclaimer — no real comparables.
**With the MCP** → `forsale_car_market("Toyota","Camry")` + `forsale_appraise("Toyota Camry 2019")`:
> *From 41 live comparable Camry listings (English + Arabic), 2019 models sit at a **median of 3,650 KWD** (typical p25–p75 band 3,200–4,100). Depreciation curve right now: 2022 → 5,400 · 2020 → 4,250 · **2019 → 3,650** · 2017 → 2,900. To sell within a week, list at ~3,500 (just under median); the cheapest comparable today is 3,100 KWD.*
### 2. "Find me an iPhone 15 Pro under 250 KWD."
**Without the MCP:**
> *I'm not able to browse 4Sale. You could search "iPhone 15 Pro" on q84sale.com and filter by price — sorry I can't pull the listings for you.*
**With the MCP** → `forsale_search_bilingual("iphone 15 pro", category_id=99)` (also searches **ايفون 15 برو**):
> *18 matching listings under 250 KWD. The English query alone returned 6; adding the Arabic term surfaced **12 more** Arabic‑only posts. Cheapest: "iPhone 15 Pro 256GB" — 235 KWD in Salmiya (link). Most are 240–290 KWD.*
The bilingual trick is the difference between 6 results and 18.
### 3. "I work in Kuwait City. Find 2‑bedroom apartments for rent within a 20‑minute drive, under 400 KWD."
**Without the MCP:**
> *I can't access live listings or compute drive times. Generally Sharq, Salmiya and Hawally are close to the city — try filtering 4Sale by those areas.*
**With the MCP** → `forsale_listings_within_drivetime("Kuwait City", max_minutes=20, query="apartment for rent")`:
> *14 apartments within ~20 min: Sharq (~3 min, 2 listings 380–400 KWD), Dasma (~5 min), Salmiya (~15 min, several 350–400 KWD), Jabriya (~13 min). Sorted by ETA with map links — Salmiya offers the most 2BRs in your budget.*
### 4. "Which area has the cheapest furnished apartments for rent?"
**Without the MCP:**
> *Prices vary a lot; outer areas like Mahboula or Mangaf are usually cheaper than Salmiya or the city — but I can't give live medians.*
**With the MCP** → `forsale_price_by_area("furnished apartment for rent")` / `forsale_cheapest_area(...)`:
> *Live median rent by area: **Mahboula 240 KWD** (cheapest, 22 listings) · Mangaf 260 · Fintas 290 · Salmiya 420 · Sharq 480. Mahboula is ~45% cheaper than Salmiya for furnished 1‑BRs right now.*
### 5. "I found a PlayStation 5 for 60 KWD — seems too cheap. Is it legit?"
**Without the MCP:**
> *That does sound low. General scam tips: meet in person, don't pay in advance, check the seller's history… (generic advice).*
**With the MCP** → `forsale_scam_risk(adv_id=…)` + `forsale_seller_reputation(adv_id=…)`:
> *⚠️ **Risk score 70/100 (high).** Reasons: asking 60 KWD is **<50% of the ~135 KWD market median**; seller is **not verified**; account is **11 days old**; contact number hidden; only 1 image. Treat with caution — insist on in‑person cash handover.*
### 6. "This phone seller looks professional — what else are they selling?"
**Without the MCP:**
> *4Sale doesn't show a public 'all listings by user' page that I can read, so I can't list their other items.*
**With the MCP** → `forsale_seller_listings_by_phone(adv_id=…, category_id=99)` + `forsale_detect_dealer(...)`:
> *That phone number appears on **23 active listings** in Mobiles — verdict: **dealer** (posing as a private seller). They're mostly selling Samsung S24/S26 and iPhone 15/16 units, 145–440 KWD. Here are all 23 with prices.*
### 7. "I'm looking at this used GMC Yukon listing — how much should I offer?"
**Without the MCP:**
> *Aim a bit below asking and negotiate — maybe 5–10% off. (No live market context.)*
**With the MCP** → `forsale_negotiation_advisor(adv_id=…)`:
> *Asking 8,900 KWD vs market estimate ~8,100 (medium confidence) → **+9.9%, above market**. Suggested opening offer **7,300**, target **8,100**. Talking point: "comparable Yukons are listed near 8,100 — would you do 8,000 cash today?"*
### 8. "Tell me when a used Nissan Patrol under 5,000 KWD gets posted."
**Without the MCP:**
> *I can't monitor listings for you — you'd need to check 4Sale periodically.*
**With the MCP** → `watch.py --add-search "used nissan patrol" --category 116 --max-price 5000`, then on a schedule:
> *🆕 New: "Nissan Patrol 2016 GCC" — 4,800 KWD in Jahra (posted 12 min ago) · ⬇️ Price drop: "Patrol Platinum" 5,200 → 4,950 KWD.* Plus `forsale_fresh_deals("nissan patrol", category_id=116)` for under‑market posts in the last 24h.
**More it answers that a plain assistant can't:** *"What's trending on 4Sale right now?"* (`forsale_trending`/`forsale_demand_index`) · *"Find verified electronics shops selling mobiles"* (`forsale_find_business_profiles`) · *"Is a used iPhone 14 or 15 better value today?"* (`forsale_compare_terms`) · *"Are there any active promo codes?"* (`forsale_valid_promo_codes` → e.g. `WELCOME25`, 25% off) · *"What filters exist for used cars?"* (`forsale_get_category_attributes`).
> The throughline: **without the MCP the assistant guesses from stale training data or sends you to the website; with it, every answer is grounded in live Kuwait listings, in both Arabic and English.**
---
## Architecture
```
MCP client ↔ server.py (62 tools, validation, slimming, structured errors)
↔ forsale_client.py (signing + HTTP + retry/cooldown + health; zero MCP deps)
reference.py (offline 509‑node taxonomy, EN↔AR dict, car makes, region data)
geo.py (Kuwait governorates/areas, haversine, drive‑time)
data/categories.json (cached taxonomy)
watch.py (standalone cron price‑drop watcher)
troubleshoot.py (live diagnostic harness)
```
| File | Purpose |
|---|---|
| `forsale_client.py` | Pure async `httpx` client. Signing, retry/backoff, rate‑limit cooldown, error classification, health probe. No MCP dependency → reusable standalone. |
| `reference.py` | Offline taxonomy (14 verticals → 163 sub‑categories → leaves; 509 nodes), fuzzy EN/AR category resolution, English→Arabic dictionary, car‑make list, listing slimming. |
| `geo.py` | 6 Kuwait governorates + ~58 areas (Arabic names + coordinates), haversine distance, place resolution, drive‑time estimation. |
| `server.py` | The FastMCP server registering all 62 tools. |
| `watch.py` | Standalone price‑drop / new‑listing watcher (cron‑ready). |
| `troubleshoot.py` | Live health + endpoint diagnostic. |
| `install.py` | Writes the `mcpServers` config block for common clients. |
| `data/categories.json` | Cached 509‑node taxonomy. |
| `evaluation/forsale_eval.xml` | Stable eval Q/A for the MCP‑builder eval harness. |
### The request‑signing scheme
Every private‑API call carries these headers:
```
Application-Source: q84sale
Version-Number: web
Device-Id: web_user_<uuid>
X-Custom-Authorization: com.forsale.forsale.web <unix_ts> <sha1_hex>
```
where:
```
ek = "/" + last‑3‑segments‑of( path [+ "?" + querystring] )
eL = JSON.stringify(body) for write requests, else '""'
msg = "com.forsale.forsale.web:" + ek + ":" + eL + ":" + ts + ":" + SECRET
sig = sha1( base64( utf8(msg) ) )
```
`ts`/`sig` are recomputed on every attempt so they never go stale.
---
## Error‑handling strategy
Every tool returns a **structured error dict** instead of raising — the agent
always gets `{error, error_type, status, hint}` (plus `endpoint`, and
`retry_after` when relevant). Failures are classified into a typed hierarchy:
| `error_type` | Exception | Trigger | What the agent should do |
|---|---|---|---|
| `transport` | `ForsaleTransportError` | network/DNS/timeout, no response | check connectivity; retry later |
| `rate_limited` | `ForsaleRateLimitError` | HTTP 403 "Forbidden"/429 anti‑abuse | back off; client sets a local cooldown; rotate `FORSALE_DEVICE_ID` |
| `auth` | `ForsaleAuthError` | 401 / "Unauthorized Request" / "not logged in" | signing mismatch (public) or pass a Bearer `auth_token` (account) |
| `not_found` | `ForsaleNotFoundError` | 404 / "no records found" | verify the id/slug/route |
| `validation` | `ForsaleValidationError` | 400/422 / malformed body | fix params (e.g. search needs query OR category) |
| `server` | `ForsaleServerError` | 5xx / legacy `status>=500` | transient backend error; retry |
| `error` | `ForsaleError` (base) | anything else | inspect message |
Layers of defense:
1. **Retry with backoff** — transient `403/429/5xx` and transport errors are
retried up to `max_retries` (default 2) with linear backoff; signature is
re‑signed each attempt.
2. **Local cooldown circuit‑breaker** — on a rate‑limit hit the client records a
`cooldown_until` (default 90 s). Subsequent calls **fail fast** with a clear
`rate_limited` error instead of hammering the anti‑abuse layer and extending
the block. `forsale_health_check` bypasses the cooldown to probe.
3. **Envelope normalisation** — the legacy monolith returns HTTP 200 with
`{status: 4xx, error:{…}}`; the client detects this and classifies it correctly.
4. **Graceful aggregation** — multi‑call tools (market stats, geo, seller) skip
individual failures rather than aborting, and per‑sub‑call errors are surfaced
under `*_error` keys.
---
## Health & rate‑limit monitoring
- **`forsale_health_check()`** — actively probes two cheap endpoints (a signed GET
and a signed POST), **bypassing the cooldown**, and returns overall
`status ∈ {ok, degraded, rate_limited, down}`, per‑endpoint results, average
latency, remaining cooldown, the active `device_id`, and a recommendation.
*Call this first whenever tools start failing.*
- **`forsale_api_status()`** — a **no‑network** snapshot of the client's telemetry:
request/error counts, last latency, last error + type, and whether a local
cooldown is active.
Example `forsale_health_check` output:
```json
{ "status": "ok", "recommendation": "API reachable and signing valid.",
"checks": [ {"endpoint": "GET v1/promo/...", "ok": true, "latency_ms": 795},
{"endpoint": "POST V4/Trends/getTrends", "ok": true, "latency_ms": 165} ],
"avg_latency_ms": 480, "cooldown_remaining_s": 0.0, "device_id": "web_user_…" }
```
---
# Tool reference (62)
All tools are async, read‑only, and return a JSON object. `lang` is `en`/`ar`
(default `en`); `region_id` defaults to `1` (Kuwait).
## Diagnostics
#### `forsale_health_check()`
Active connectivity + signing + rate‑limit probe (see above).
**Returns:** `{status, recommendation, checks[], avg_latency_ms, cooldown_remaining_s, device_id, telemetry}`.
#### `forsale_api_status()`
No‑network telemetry snapshot.
**Returns:** `{device_id, cooldown_remaining_s, in_cooldown, requests, errors, rate_limited_hits, last_status, last_latency_ms, last_error, last_error_type}`.
## Search & discovery
#### `forsale_search(query="", category_id=None, sorting="newest", page=1, page_size=18, region_id=1, lang="en")`
Core listing search (advancedSearch). Provide `query` and/or `category_id`.
`sorting` ∈ `newest|oldest|price_asc|price_desc`.
**Returns:** `{total, pages, page, count, items[]}`; each item `{id, title, price, district_name, image, url, …}`.
#### `forsale_search_bilingual(query, category_id=None, pages=1, region_id=1, sorting="newest")`
Searches the **English term and its Arabic equivalent**, merges & de‑dupes by id — maximises coverage (most listings are Arabic‑only).
**Returns:** `{query_en, query_ar, count, items[]}`.
#### `forsale_autocomplete(query, lang="en")`
Keyword suggestions, each mapped to a `listing_category` and `predefined_filters` (category/sub_category ids) — use those for precise searches.
**Returns:** `{suggestions[]}`.
#### `forsale_browse_category(category_id, sorting="newest", page=1, page_size=18, region_id=1, lang="en")`
Browse all listings in a category, no keyword (getListings).
**Returns:** `{total, pages, page, count, items[]}`.
#### `forsale_latest_listings(page=1, page_size=18, region_id=1, lang="en")`
Newest listings across the whole marketplace.
**Returns:** `{total, pages, page, count, items[]}`.
#### `forsale_trending(lang="en")`
Currently trending search terms (bilingual).
**Returns:** `{title:{ar,en}, trends:[{ar,en}]}`.
#### `forsale_recommended_listings(adv_id, category_id, region_id=1, lang="en")`
Recommendation engine — related/similar listings for a listing.
**Returns:** `{total, pages, count, items[]}`.
#### `forsale_search_cards(category_id, query, lang="en")`
Sponsored/marketing cards shown for a search (campaign banners, lead‑gen).
**Returns:** `{cards[]}`.
#### `forsale_map_recommended_listings(category_id, lang="en")`
Map‑based recommended listings for a category.
**Returns:** recommendation payload `{listings:{…}, …}`.
## Listings
#### `forsale_get_listing(adv_id, lang="en")`
Raw full listing record by id.
**Returns:** `{id, title, description, price, contact_no, images[], geotag_lat, geotag_lon, district_id, …}`.
#### `forsale_get_listing_details(adv_id, lang="en")`
Rich detail: bilingual description, dynamic attributes (`attrs`), category breadcrumbs, district, geotag, images, bundle/plan **and the seller `user_id`**.
**Returns:** the detail object.
#### `forsale_get_listing_stats(listing_id, lang="en")`
Engagement: quality score (0–100), CTA count, and the live view counter.
**Returns:** `{listing_id, score:{listing_score, ctas_count}, views:{user_views_count}}`.
#### `forsale_listing_full_report(adv_id, lang="en")`
One‑shot rich report: full details + stats + seller reputation + similar listings.
**Returns:** `{adv_id, details, seller, stats, similar}`.
## Sellers & reputation
#### `forsale_get_seller_profile(slug, lang="en")`
Verified business/shop profile by slug (e.g. `unlimited-tech-474`).
**Returns:** `{id, user_id, name, slug, logo, member_since, contact_numbers, is_verified, is_booking_enabled, …}`.
#### `forsale_find_business_profiles(category_ids, lang="en")`
Verified shops operating in the given category id(s), with each shop's matched‑item count. `category_ids` is a list, e.g. `[99]`.
**Returns:** `{profiles[]}`.
#### `forsale_get_listing_seller(adv_id, lang="en")`
Resolve the seller behind a listing.
**Returns:** `{user_id, name, is_verified, user_type, member_since, listings_count, business_profile_slug, risk_flags[]}`.
#### `forsale_seller_reputation(slug=None, adv_id=None, lang="en")`
Trust assessment. Pass a business‑profile `slug` (preferred for shops) OR a listing `adv_id` (any seller).
**Returns:** verification, account age, inventory size, `trust_signals` and `risk_flags`.
#### `forsale_seller_service_ads(listing_id, user_id, category_id, lang="en")`
Other service ads by the same provider (service categories). Get `user_id`/`category_id` from `forsale_get_listing_details`.
**Returns:** `{services[]}`.
## Seller inventory & fraud
#### `forsale_seller_listings_by_phone(phone=None, adv_id=None, query="", category_id=None, pages=4)`
Enumerate an individual seller's inventory by **grouping on contact phone** — the practical "all listings by a seller" (4Sale has no public member feed). Provide a `phone`, or an `adv_id` whose phone is looked up. Scope with `query`/`category_id` for recall.
**Returns:** `{phone_tail, scope, listings_found, listings[]}`.
#### `forsale_detect_dealer(adv_id, category_id=None, pages=4)`
Is a "private" seller really a dealer? Counts listings sharing the phone in the category.
**Returns:** `{adv_id, phone_tail, listings_with_same_phone, verdict ∈ individual|active_seller|dealer}`.
#### `forsale_scam_risk(adv_id, category_id=None)`
Heuristic scam‑risk score (0–100): below‑market price + unverified/new seller + hidden contact + few images + thin description. Educational signal, **not** a verdict.
**Returns:** `{risk_score, risk_level, asking_price, market_estimate, reasons[], disclaimer}`.
## Cars (attribute‑aware)
#### `forsale_search_cars(make, model="", year_min=None, year_max=None, price_min=None, price_max=None, max_mileage=None, condition="used", sorting="newest", pages=3)`
Resolves `make`→category and `model`→sub‑category (server‑side, bilingual), then filters by **price, model year and mileage** parsed from each listing. `condition` ∈ `used|new`. Year/price filters drop listings whose value can't be parsed; the mileage filter only drops when a value is found.
**Returns:** `{make, model, condition, resolved_category, resolved_sub_category, filters_applied, count, cars[]}` (cars include parsed `year`, `mileage_km`).
#### `forsale_car_models(make)`
Models (sub‑categories) for a make, via autocomplete.
**Returns:** `{make, models:[{model, category, sub_category}]}`.
#### `forsale_car_market(make, model="", condition="used", pages=3)`
Car market analysis: overall price stats **plus a price‑by‑model‑year breakdown** (a rough depreciation curve).
**Returns:** `{make, model, resolved_category, sample, overall:{min,max,avg,median,p25,p75}, by_year:[{year,count,median_price}]}`.
#### `forsale_car_attributes(condition="used", lang="en")`
Live attribute schema for the cars category (make/model/year/mileage/transmission fields).
**Returns:** `{category_id, attributes}`.
## Location intelligence
#### `forsale_list_areas()`
Kuwait governorates + ~58 known areas (Arabic names + coordinates) usable as place names.
**Returns:** `{governorates[], areas[]}`.
#### `forsale_resolve_area(name)`
Resolve a place name (EN/AR) → coordinates + governorate.
**Returns:** `{name, name_ar, type ∈ area|governorate, governorate, lat, lon}`.
#### `forsale_get_districts(region_id=1, lang="en")`
Live 4Sale district/governorate master list.
**Returns:** `{districts}`.
#### `forsale_get_subdistricts(district_id, lang="en")`
Child districts of a district id.
**Returns:** `{districts}`.
#### `forsale_listing_location(adv_id, lang="en")`
Pin a listing on the map: geotag, nearest known area & governorate, Google Maps link, district id.
**Returns:** `{adv_id, title, price, lat, lon, district_id, maps_link, nearest:{area, governorate, distance_km}}`.
#### `forsale_listings_near_place(place, query="", category_id=None, radius_km=5.0, pages=2, precise=False, max_precise=12)`
Listings within `radius_km` of a Kuwait place. Default uses each listing's area centroid (fast, no extra calls); `precise=True` fetches exact geotags for up to `max_precise` listings.
**Returns:** `{center, radius_km, mode, count, listings[]}` (each with `approx_distance_km`, or `distance_km` in precise mode).
#### `forsale_price_by_area(query, category_id=None, pages=3, region_id=1)`
**Price heatmap**: median price + count per area for an item (bilingual, outlier‑trimmed).
**Returns:** `{query, areas_covered, by_area:[{area, listings, median, min, max}]}` (sorted cheapest→priciest).
#### `forsale_listing_density_by_area(query="", category_id=None, pages=3, region_id=1)`
**Supply map**: listing count per area (where inventory concentrates).
**Returns:** `{query, sample, areas:[{area, listings}]}`.
#### `forsale_cheapest_area(query, category_id=None, pages=3)`
Which area has the lowest median price for an item (areas with ≥2 listings).
**Returns:** `{query, cheapest, priciest, ranked[]}`.
#### `forsale_compare_areas(query, areas, category_id=None, pages=3)`
Compare price & supply across specific areas (relocation / buy‑where). `areas` is a list of names.
**Returns:** `{query, comparison:{area: {listings, median, …}}}`.
## Commute / drive‑time
#### `forsale_listings_within_drivetime(destination, max_minutes=20, query="", category_id=None, avg_speed_kmh=45, pages=2)`
Listings within an **estimated** N‑minute drive of a destination (road‑detour speed model over area centroids; not live traffic). Sorted by ETA.
**Returns:** `{destination, max_minutes, avg_speed_kmh, count, listings[]}` (each with `approx_km`, `est_drive_min`).
#### `forsale_commute_estimate(adv_id, destination, avg_speed_kmh=45, lang="en")`
Driving distance & ETA between a specific listing (precise geotag, falls back to district centroid) and a destination.
**Returns:** `{adv_id, title, destination, location_source, distance_km, est_drive_min, maps_link}`.
## Taxonomy & attributes
#### `forsale_list_verticals()`
The 14 top‑level categories with ids + EN/AR names.
**Returns:** `{verticals:[{id, name_en, name_ar, slug}]}`.
#### `forsale_list_category_children(category_id)`
Direct children of a category (from the cached 509‑node tree), sorted by listing volume.
**Returns:** `{parent, children:[{id, name_en, name_ar, slug, listings_count, is_leaf, slug_url}]}`.
#### `forsale_resolve_category(query, limit=12)`
Fuzzy EN/AR name/slug → category ids.
**Returns:** `{matches:[{id, name_en, name_ar, slug, parent_id, listings_count, slug_url}]}`.
#### `forsale_get_category_info(category_id, region_id=1, lang="en")`
Live category metadata + marketing description.
**Returns:** category object `{en_name, name, en_description, …}`.
#### `forsale_get_category_attributes(category_id, region_id=1, lang="en")`
Live attribute schema for a category (filters/fields, dropdowns, ranges, booleans).
**Returns:** attribute schema.
## Market intelligence
#### `forsale_price_stats(query, category_id=None, pages=2, bilingual=True, region_id=1)`
min/max/avg/median/p25/p75 over a sample (bilingual, outlier‑trimmed) + cheapest & priciest matches.
**Returns:** `{query, sample_size, priced, stats, cheapest, priciest}`.
#### `forsale_price_distribution(query, category_id=None, buckets=6, pages=2, region_id=1)`
Price histogram across `buckets` ranges.
**Returns:** `{query, min, max, buckets:[{range:[lo,hi], count}]}`.
#### `forsale_find_deals(query, category_id=None, below_pct=25, pages=3, region_id=1)`
Listings priced at least `below_pct`% under the median.
**Returns:** `{query, median, threshold, count, deals[]}`.
#### `forsale_fresh_deals(query, category_id=None, hours=24, below_pct=15, pages=4)`
Newly posted (last `hours`) listings priced under market — first‑mover sniping.
**Returns:** `{query, market_median, hours, below_pct, count, deals[]}` (each with `pct_below_median`).
#### `forsale_compare_terms(terms, category_id=None, pages=2, region_id=1)`
Compare volume + price stats across multiple item terms. `terms` is a list (≤5).
**Returns:** `{comparison:{term: {listings, stats}}}`.
#### `forsale_appraise(query, category_id=None, pages=3)`
Fair‑value estimate from comparables: median value, typical band (p25–p75), full range, sample size, confidence.
**Returns:** `{query, estimated_value, typical_range, full_range, sample_size, confidence}`.
#### `forsale_market_pulse(lang="en")`
Quick snapshot: trending terms + a sample of the latest listings + active promo codes.
**Returns:** `{trending, latest, promos}`.
## Negotiation & coverage helpers
#### `forsale_negotiation_advisor(adv_id, category_id=None)`
Compares a listing's asking price to the market and suggests an opening offer + target + talking points.
**Returns:** `{adv_id, title, asking_price, market_estimate, vs_market_pct, assessment, suggested_opening_offer, suggested_target, talking_points[]}`.
#### `forsale_arabic_variants(term)`
Generate Arabic spelling variants (alef/hamza, ya/alef‑maqsura, ta‑marbuta) to run several searches and catch differently‑spelled listings.
**Returns:** `{input, arabic, variants[], source}`.
## Trends, promotions, SDUI
#### `forsale_demand_index(enrich=False)`
"What's hot" index from trending searches. With `enrich=True`, fetches live result counts per trend (slower) and ranks by demand.
**Returns:** `{trending_count, index:[{term, ar, listings?}]}`.
#### `forsale_top_viewed(region_id=1, lang="en")`
Most‑viewed listings (Analytics/getTopViewed).
**Returns:** API payload (may be null when unavailable).
#### `forsale_valid_promo_codes(lang="en")`
Currently active promo/discount codes.
**Returns:** `{promo_codes:[{promo_code, discount_type, discount_value, max_discount, expiry_date, …}]}`.
#### `forsale_sdui_component(name="floating-timer-banner", os_name="web", version=1, lang="en")`
Fetch a Server‑Driven UI component spec — component, action/deeplink and analytics directives.
**Returns:** `{component, action, analytics, …}`.
#### `forsale_core_cards(screen_name, adv_id, category_id=None, lang="en")`
Contextual SDUI cards for a screen (e.g. `screen_name="listing_details_screen"`).
**Returns:** `{cards[]}`.
## Geography & translation
#### `forsale_list_regions()`
Region ids usable as `region_id`.
**Returns:** `{regions:[{id, en, ar}]}`.
#### `forsale_translate_term(term)`
English → Arabic search term (+ matching category names). Word‑level for multi‑word inputs (e.g. "iphone 15" → "ايفون 15").
**Returns:** `{input, arabic, source, category_matches[]}`.
## Authenticated (optional Bearer token)
Pass a token via the `auth_token` argument or the `FORSALE_AUTH_TOKEN` env var.
Anonymous sessions return an `auth` error.
#### `forsale_my_account(auth_token=None, lang="en")`
The signed‑in user's account profile.
#### `forsale_my_favorites(auth_token=None, page=1, lang="en")`
The signed‑in user's saved/favorite listings.
---
## The bilingual coverage trick 🔑
Most 4Sale listings are titled in **Arabic only**, so an English‑only search
misses them. `forsale_search_bilingual`, `forsale_price_stats`,
`forsale_find_deals`, `forsale_fresh_deals`, `forsale_compare_terms`,
`forsale_appraise`, `forsale_price_by_area` and the car tools automatically query
**both** the English term and its Arabic equivalent (via `forsale_translate_term`)
and merge — typically a large increase in coverage. For tricky spellings, expand
further with `forsale_arabic_variants`.
---
## Price‑drop watcher (`watch.py`)
A standalone, cron‑ready watcher (httpx only, no MCP) that tracks searches or
specific listings between runs and reports **price drops/rises, new listings and
removed listings**. State lives in `watch_state.json`; watches in `watches.json`
(auto‑created with an example).
```bash
python watch.py --add-search "used nissan patrol" --category 116 --max-price 5000
python watch.py # run all watches, print alerts, update state
python watch.py --json # machine-readable alerts
python watch.py --list # show configured watches
```
Schedule every 30–60 min (cron / Task Scheduler). Example output:
```
## iPhone 15 under 200 KWD
⬇️ iPhone 15 128GB 185 → 165 KWD (-20) https://www.q84sale.com/en/listing/...
🆕 iPhone 15 Plus 190 KWD https://www.q84sale.com/en/listing/...
```
---
## Setup — quick start (3 steps)
Works with any MCP‑capable agent: **Claude Desktop, Cursor, Windsurf, Google
Antigravity, VS Code (Copilot), Cline, Zed**, and more.
### Step 1 — Get the code
```bash
git clone https://github.com/isaamthalhath07/4sale-mcp.git
cd 4sale-mcp
```
### Step 2 — Install dependencies
Pick **one** (Python 3.10+ required):
```bash
# Option A — uv (recommended; no manual venv)
uv sync # or: uv pip install -r requirements.txt
# Option B — pip
pip install -r requirements.txt
```
### Step 3 — Smoke‑test it (optional but recommended)
```bash
python troubleshoot.py
```
You should see `[HEALTH] status=ok …` and a column of `[PASS]` lines. If so,
you're ready to connect it to your agent below.
---
## Connect it to your LLM agent
Every client uses the same idea: add a **server entry** that tells the client how
to launch `server.py`. Use the **uv** block if you installed with uv, or the
**python** block if you used pip. **Replace `/ABSOLUTE/PATH/TO/4sale-mcp`** with
the real path where you cloned the repo (e.g. `C:\\Users\\you\\4sale-mcp` or
`/Users/you/4sale-mcp`).
**uv block:**
```json
{
"mcpServers": {
"forsale": {
"command": "uv",
"args": ["run", "--directory", "/ABSOLUTE/PATH/TO/4sale-mcp", "server.py"],
"env": { "PYTHONIOENCODING": "utf-8" }
}
}
}
```
**python (pip) block** — use the full path to your Python if `python` isn't on PATH:
```json
{
"mcpServers": {
"forsale": {
"command": "python",
"args": ["/ABSOLUTE/PATH/TO/4sale-mcp/server.py"],
"env": { "PYTHONIOENCODING": "utf-8" }
}
}
}
```
> 💡 **Shortcut:** run `python install.py` from the repo and it will auto‑detect
> Claude Desktop / Cursor / Windsurf and write the correct block for you. Use
> `python install.py --print` to just print the block to paste anywhere.
### Claude Desktop
1. Open the config file (create it if missing):
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
2. Paste the block above (merge into any existing `"mcpServers"`).
3. **Fully quit and reopen** Claude Desktop. The 🔌/tools icon should show `forsale`.
### Cursor
1. Settings → **MCP** → *Add new global MCP server* (or edit `~/.cursor/mcp.json`;
for one project use `<project>/.cursor/mcp.json`).
2. Paste the block.
3. Reload — the server appears under Settings → MCP with a green dot. Ask in
Agent/Composer chat.
### Windsurf
1. Open `~/.codeium/windsurf/mcp_config.json` (or Settings → **Cascade → MCP
Servers → Manage → View raw config**).
2. Paste the block and save.
3. Click **Refresh** in the MCP panel. Use the tools from Cascade.
### Google Antigravity
1. Open **Settings → MCP / Tools** (the MCP servers panel) and choose *Add server
/ Edit config (JSON)*.
2. Paste the same `"mcpServers"` block (Antigravity uses the standard MCP config
schema). Save.
3. Reload the workspace; the `forsale` tools become available to the agent.
### VS Code (GitHub Copilot — native MCP)
VS Code uses a slightly different key (`servers`, not `mcpServers`). Create
`.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"forsale": {
"command": "uv",
"args": ["run", "--directory", "/ABSOLUTE/PATH/TO/4sale-mcp", "server.py"],
"env": { "PYTHONIOENCODING": "utf-8" }
}
}
}
```
Then open **Copilot Chat → Agent mode** and pick the tools.
### Cline (VS Code extension)
Cline → **MCP Servers → Configure MCP Servers** → add the standard `"mcpServers"`
block to `cline_mcp_settings.json`, then toggle the server on.
### Any other MCP client (Zed, LibreChat, custom)
They all accept a command + args. Point the command at `uv run --directory
<path> server.py` (or `python <path>/server.py`). Transport is **stdio**.
### First things to ask your agent
- *"Run a 4Sale health check."*
- *"Search 4Sale for an iPhone 15 in both English and Arabic, cheapest first."*
- *"What's the median price of a used Nissan Patrol in Kuwait right now?"*
- *"Find 2‑bedroom apartments within a 20‑minute drive of Kuwait City under 400 KWD."*
### Optional environment variables
| Var | Effect |
|---|---|
| `FORSALE_DEVICE_ID` | Override the generated `web_user_<uuid>` device id (rotate if rate‑limited). |
| `FORSALE_AUTH_TOKEN` | Bearer token for the authenticated tools (`forsale_my_account`, `forsale_my_favorites`). |
| `PYTHONIOENCODING=utf-8` | Ensures Arabic prints correctly on Windows. |
Add them under the `"env"` object of your server block, e.g.
`"env": { "PYTHONIOENCODING": "utf-8", "FORSALE_DEVICE_ID": "web_user_my-id" }`.
### Troubleshooting the connection
- **Server doesn't appear / "failed to start":** the `command` isn't on the
client's PATH. Use an absolute path to `uv`/`python`, and an absolute
`--directory`.
- **Tools error with `rate_limited`:** you've been throttled — wait, or set a
fresh `FORSALE_DEVICE_ID`. Run `forsale_health_check` to confirm.
- **Arabic shows as `????`:** ensure `PYTHONIOENCODING=utf-8` is in `env`.
- Re‑run `python troubleshoot.py` to confirm the server itself is healthy
independent of the client.
---
## Verifying it works (`troubleshoot.py`)
```bash
python troubleshoot.py
```
Runs offline taxonomy checks, a **health check**, then ~12 live endpoint probes,
printing `PASS` / `FAIL` / `RATE-LIMITED` / `AUTH-FAIL` / `NOT-FOUND` per check
with the actionable hint. Sample:
```
[PASS] offline categories: 509 nodes, 14 verticals
[HEALTH] status=ok avg_latency=364ms cooldown=0.0s — API reachable and signing valid.
[PASS] advancedSearch (en): total=347
[PASS] advancedSearch (ar): ar='ايفون' total=333
...
```
If you see `RATE-LIMITED`, the anti‑abuse layer has temporarily blocked your
IP/device — wait a few minutes and/or set a fresh `FORSALE_DEVICE_ID`.
---
## Limitations & notes
- **Read‑only**; no posting/editing/deleting.
- **Kuwait market** (`region_id = 1`).
- **Unofficial API** — endpoints/signing can change; re‑verify with `troubleshoot.py` / `forsale_health_check`.
- **Rate limiting** — keep volume modest; the client backs off and cools down.
- Drive‑times are **estimates** (straight‑line × detour factor ÷ avg speed), not live traffic.
- A regular member's *complete* inventory isn't a public endpoint; the phone‑grouping tools approximate it within a search scope.
- Area coordinates are approximate centroids — good for proximity/comparison, not survey‑grade geocoding.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues