Skip to main content
Glama
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.