forsale-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@forsale-mcpWhat's the median price for a 2019 Toyota Camry?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: eBay MCP Server
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 |
| Pure async |
| Offline taxonomy (14 verticals → 163 sub‑categories → leaves; 509 nodes), fuzzy EN/AR category resolution, English→Arabic dictionary, car‑make list, listing slimming. |
| 6 Kuwait governorates + ~58 areas (Arabic names + coordinates), haversine distance, place resolution, drive‑time estimation. |
| The FastMCP server registering all 62 tools. |
| Standalone price‑drop / new‑listing watcher (cron‑ready). |
| Live health + endpoint diagnostic. |
| Writes the |
| Cached 509‑node taxonomy. |
| 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:
| Exception | Trigger | What the agent should do |
|
| network/DNS/timeout, no response | check connectivity; retry later |
|
| HTTP 403 "Forbidden"/429 anti‑abuse | back off; client sets a local cooldown; rotate |
|
| 401 / "Unauthorized Request" / "not logged in" | signing mismatch (public) or pass a Bearer |
|
| 404 / "no records found" | verify the id/slug/route |
|
| 400/422 / malformed body | fix params (e.g. search needs query OR category) |
|
| 5xx / legacy | transient backend error; retry |
|
| 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 watchesSchedule 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-mcpStep 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.txtStep 3 — Smoke‑test it (optional but recommended)
python troubleshoot.pyYou 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.jsonmacOS:
~/Library/Application Support/Claude/claude_desktop_config.json
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 |
| Override the generated |
| Bearer token for the authenticated tools ( |
| 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.pyRuns 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 installed
Maintenance
Latest Blog Posts
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/isaamthalhath07/4sale-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server