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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues