Skip to main content
Glama

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 MCPforsale_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 MCPforsale_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 MCPforsale_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 MCPforsale_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 MCPforsale_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 MCPforsale_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 MCPforsale_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 MCPwatch.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:

{ "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. sortingnewest|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[]}.

Currently trending search terms (bilingual). Returns: {title:{ar,en}, trends:[{ar,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[]}.

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. conditionused|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}.

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
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.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 → MCPAdd 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:

{
  "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)

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.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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