NoonMCP
by MrOplus
README.md
# NoonMCP
An **MCP server** exposing noon.com's product catalog — search, browse, product
detail, price comparison and discount scanning — to any MCP client (Claude Code,
Claude Desktop, or your own agent).
Defaults to the **UAE** storefront (prices in AED); all seven GCC/MENA
storefronts are supported.
---
## Tools
| Tool | What it does |
|---|---|
| `search_products` | Keyword search, with price / brand / deal-tag filters and sorting |
| `browse_category` | List a category by its noon URL path |
| `find_discounted_deals` | Scan categories and rank by discount, with quality gates |
| `get_product` | Full detail for one SKU: every seller's offer, specs, highlights |
| `compare_prices` | Several SKUs side by side, ranked by discount |
| `list_categories` | Valid category slugs, deal-tag facets and sort fields |
| `search` / `fetch` | ChatGPT compatibility pair (see **ChatGPT** below) |
Every product result carries `price` (was), `sale_price` (current),
`discount_percent`, `saving`, rating, seller and a direct link.
---
## Transports
| Command | Transport | For |
|---|---|---|
| `python server.py` | stdio | Claude Desktop / Claude Code |
| `python server.py --http` | Streamable HTTP on `:8000/mcp` | **ChatGPT connectors**, remote clients |
| `python server.py --sse` | SSE on `:8000/sse` | Older remote MCP clients |
`--host` and `--port` override the bind address; `NOON_TRANSPORT=http` does the
same via environment.
## Setup
```bash
pip install -r requirements.txt
python server.py # stdio
python server.py --http --port 8000
```
### ChatGPT
ChatGPT requires a **remote server over HTTPS** — it cannot reach localhost, so
the server needs a tunnel or real hosting.
Its deep-research and company-knowledge paths also require two read-only tools
named exactly **`search`** and **`fetch`**, returning a fixed compatibility
schema (`structuredContent` plus the same JSON duplicated as text content).
Both are implemented and verified over the wire — `search` returns
`{results: [{id, title, url}]}` and `fetch` returns
`{id, title, text, url, metadata}`.
The `id` is the noon SKU, so ChatGPT can search then fetch a full product
record. The richer tools remain available to clients that allow arbitrary tools
(Claude, and ChatGPT developer mode).
Add it in ChatGPT under **Settings → Connectors → Add custom connector**, using
the `/mcp` URL.
### Claude Code
```bash
claude mcp add noon -- python E:/Workspace/NoonMCP/server.py
```
### Claude Desktop — `claude_desktop_config.json`
```json
{
"mcpServers": {
"noon": {
"command": "python",
"args": ["E:/Workspace/NoonMCP/server.py"],
"env": { "NOON_LOCALE": "en-ae" }
}
}
}
```
### Environment (all transports)
| Variable | Default | Notes |
|---|---|---|
| `NOON_LOCALE` | `en-ae` | `en-ae`, `en-sa`, `en-om`, `en-eg`, `en-kw`, `en-bh`, `en-qa` |
| `NOON_MAX_RESULTS` | `50` | Hard cap on products per response |
| `NOON_HOST` | `0.0.0.0` | Bind address for HTTP transports |
| `NOON_PORT` | `8000` | Port for HTTP transports |
| `NOON_TRANSPORT` | `stdio` | `stdio`, `http` or `sse` |
| `LOG_LEVEL` | `WARNING` | Logs go to **stderr** — stdout is the MCP transport |
All seven are verified against the live site.
**`x-mp-country` must match the market.** Hardcoding `ae` appears to work for
AE/OM/KW/BH/QA but makes **SA and EG return zero hits with HTTP 200** — a silent
empty result rather than an error. The client derives it from the locale.
The Saudi storefront HTML returns 403 to this client, so the session cannot be
warmed there; warmup falls back to another storefront, which seeds the same
Akamai cookies.
---
## How noon.com actually works
Reverse-engineered by instrumenting the live storefront in Chrome DevTools.
These findings are what the implementation is built on.
### Endpoints
```
GET /_vs/nc/mp-customer-catalog-api/api/v3/search?q=<query>
GET /_vs/nc/mp-customer-catalog-api/api/v3/u/<category-path>/
GET /_vs/nc/mp-customer-catalog-api/api/v3/product/<sku>
GET /_vs/nc/mp-customer-catalog-api/api/v3/u/desktop-navigation/
```
`<category-path>` is exactly the path from the website URL, so
`/uae-en/beauty/makeup-16142/` becomes `.../api/v3/u/beauty/makeup-16142/`.
Search and category listings share one schema: `nbHits`, `nbPages`, `facets`,
`hits[]`.
### Akamai Bot Manager — the load-bearing constraint
noon.com sits behind Akamai. On `/_vs/` paths:
- `requests`, `httpx`, Node `http2` and `curl` without nghttp2 all get their
HTTP/2 stream **reset with `INTERNAL_ERROR`** — *even carrying valid session
cookies harvested from a real browser*. The block keys on the
**TLS/HTTP2 fingerprint**, not the session.
- Requests issued from Chrome DevTools via CDP are blocked too, while the
page's own XHRs succeed — so in-browser scraping is not a way around it.
The fix is [`curl_cffi`](https://github.com/lexiforest/curl_cffi) with Chrome
impersonation, reproducing Chrome's JA3/JA4 and HTTP/2 SETTINGS.
**Swapping it for `requests` breaks the server.**
Two further gotchas:
1. **Warm up first.** Fetch the storefront HTML once to seed the Akamai cookies
(`AKA_A2`, `ak_bmsc`, `bm_s`, `bm_so`). The first `/_vs/` call without this
is reset.
2. **Send minimal headers.** Only `accept`, `x-locale`, `x-mp-country` and
`referer`. Adding the frontend's `x-platform` / `x-content` / `x-cms` makes
search and listings reset — those belong *only* on `desktop-navigation`.
Replaying the browser's full header set also fails, because the frontend
emits `x-ab-test:` with an empty value and Envoy resets on it.
### Pricing — easy to get backwards
```jsonc
{ "price": 56.92, // the WAS / reference price
"sale_price": 34.80 } // the CURRENT selling price; null when not discounted
```
There is **no** `discount_percent` field; it is computed here.
On **product detail** the shape differs: the name is `product_title`,
`buying_options` is empty, and pricing lives in **`variants[].offers[]`** —
one entry per seller, each with its own `price`, `sale_price`, `stock` and
`store_name`.
### No discount sort
`sort[by]=discount` is **silently ignored** — the API falls back to popularity.
Supported sorts: `popularity`, `price`, `best_rating`, `new_arrivals`.
`find_discounted_deals` therefore pulls pages and ranks client-side, and the
client raises rather than pretending a discount sort worked.
### Limits
`limit` caps at ~200 and `nbPages` at 50, so any single query reaches at most
~10,000 products.
### Deal facets
Live counts observed on the Oman storefront (Electronics):
| `f[deal_tag][]` | Count |
|---|---|
| `mega-deal` | ~72,900 |
| `deals-generic` | ~97,800 |
| `Clearances-deal-tag` | ~34,100 |
| `flash-sale-tag` | ~245 |
---
## Why `find_discounted_deals` is more than a threshold
Measured on live data, not assumed:
- A bare "40% off" filter matched **54%** of products carrying the `mega-deal`
tag, and the deepest "discounts" were filler — a **1.20 OMR pack of coat
hangers** marked down from 18.01. Hence `min_price` and `min_saving`.
- noon lists every colourway as its own SKU, so one scan surfaced **four Galaxy
S25 Ultra colours** and two identical roborock listings in a single top ten.
Hence variant collapsing, which keeps the deepest discount per product.
A live 3-category scan: 600 products → 115 qualifying at ≥50% → 112 after
collapsing, in 2.3s.
```
84.6% 6.42 (was 41.60) save 35.18 Man Eau de Toilette 100ml
78.5% 15.01 (was 69.72) save 54.71 Korean Brand Z8 Smart Watch
75.2% 19.52 (was 78.66) save 59.14 16 Inch Stand Fan, 60W
```
Scanning is the slow part — each category/tag/page is one request — so keep
`pages_per_category` small for interactive use.
---
## Deploying as a remote server
Any Docker host works. Build and run:
```bash
docker build -t noon-mcp .
docker run -d --name noon-mcp --restart unless-stopped -p 8000:8000 -e NOON_LOCALE=en-ae noon-mcp
```
Or with compose:
```yaml
services:
noon-mcp:
build: .
restart: unless-stopped
ports: ["8000:8000"]
environment:
NOON_LOCALE: en-ae
```
The image defaults to `NOON_TRANSPORT=http`, so it serves Streamable HTTP on
`:8000/mcp`. Its healthcheck performs a real `initialize` round-trip rather than
a bare `GET` — a GET returns 406, because Streamable HTTP requires the client to
accept both `application/json` and `text/event-stream`, and it would pass even
if the MCP layer were broken.
**ChatGPT needs HTTPS**, so put it behind a reverse proxy or tunnel
(Caddy, Traefik, nginx, Cloudflare Tunnel, ngrok) and point the connector at
`https://your-host/mcp`.
**The endpoint is unauthenticated.** It is read-only against public noon data,
but anyone with the URL can call it — add a proxy-level token or access policy
if that matters to you.
## Tests
```bash
python -m pytest tests/ -q # 17 tests, no network required
```
---
## SDK pin
`requirements.txt` pins **`mcp>=1.2,<2`**. The v2 SDK renamed `FastMCP` to
`MCPServer` and changed the tool and transport APIs; this server targets the v1
API it was built and tested against. Without the pin a fresh install picks up
2.x and the server fails to import.
## Notes
- This reads the same public JSON the website serves to any visitor. Requests
are throttled (0.3s) and retried with backoff; keep concurrency modest.
- It depends on an **internal, undocumented API**. noon can change field names,
paths or bot rules without notice. If everything starts returning errors,
check whether the header set or impersonation profile still passes.