Read-only MCP server for Kuwait's 4Sale marketplace (q84sale.com): 62 tools for search, listings, sellers & reputation, location & car intelligence, market analytics, plus a price-drop watcher.
4Sale MCP Server (forsale-mcp)
An extensive, read‑only Model Context Protocol
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:
- Retry with backoff — transient
403/429/5xxand transport errors are retried up tomax_retries(default 2) with linear backoff; signature is re‑signed each attempt. - Local cooldown circuit‑breaker — on a rate‑limit hit the client records a
cooldown_until(default 90 s). Subsequent calls fail fast with a clearrate_limitederror instead of hammering the anti‑abuse layer and extending the block.forsale_health_checkbypasses the cooldown to probe. - Envelope normalisation — the legacy monolith returns HTTP 200 with
{status: 4xx, error:{…}}; the client detects this and classifies it correctly. - Graceful aggregation — multi‑call tools (market stats, geo, seller) skip
individual failures rather than aborting, and per‑sub‑call errors are surfaced
under
*_errorkeys.
Health & rate‑limit monitoring
forsale_health_check()— actively probes two cheap endpoints (a signed GET and a signed POST), bypassing the cooldown, and returns overallstatus ∈ {ok, degraded, rate_limited, down}, per‑endpoint results, average latency, remaining cooldown, the activedevice_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:
{ "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).
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
git clone https://github.com/isaamthalhath07/4sale-mcp.git
cd 4sale-mcp
Step 2 — Install dependencies
Pick one (Python 3.10+ required):
# 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)
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:
{
"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:
{
"mcpServers": {
"forsale": {
"command": "python",
"args": ["/ABSOLUTE/PATH/TO/4sale-mcp/server.py"],
"env": { "PYTHONIOENCODING": "utf-8" }
}
}
}
💡 Shortcut: run
python install.pyfrom the repo and it will auto‑detect Claude Desktop / Cursor / Windsurf and write the correct block for you. Usepython install.py --printto just print the block to paste anywhere.
Claude Desktop
- Open the config file (create it if missing):
- Windows:
%APPDATA%\Claude\claude_desktop_config.json - macOS:
~/Library/Application Support/Claude/claude_desktop_config.json
- Windows:
- Paste the block above (merge into any existing
"mcpServers"). - Fully quit and reopen Claude Desktop. The 🔌/tools icon should show
forsale.
Cursor
- Settings → MCP → Add new global MCP server (or edit
~/.cursor/mcp.json; for one project use<project>/.cursor/mcp.json). - Paste the block.
- Reload — the server appears under Settings → MCP with a green dot. Ask in Agent/Composer chat.
Windsurf
- Open
~/.codeium/windsurf/mcp_config.json(or Settings → Cascade → MCP Servers → Manage → View raw config). - Paste the block and save.
- Click Refresh in the MCP panel. Use the tools from Cascade.
Google Antigravity
- Open Settings → MCP / Tools (the MCP servers panel) and choose Add server / Edit config (JSON).
- Paste the same
"mcpServers"block (Antigravity uses the standard MCP config schema). Save. - Reload the workspace; the
forsaletools 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:
{
"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
commandisn't on the client's PATH. Use an absolute path touv/python, and an absolute--directory. - Tools error with
rate_limited: you've been throttled — wait, or set a freshFORSALE_DEVICE_ID. Runforsale_health_checkto confirm. - Arabic shows as
????: ensurePYTHONIOENCODING=utf-8is inenv. - Re‑run
python troubleshoot.pyto confirm the server itself is healthy independent of the client.
Verifying it works (troubleshoot.py)
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.