Skip to main content
Glama
README.md
# letu-mcp

**English** · [Русский](README.ru.md)

An [MCP](https://modelcontextprotocol.io) server that gives LLM agents live,
honestly-labelled data from [ЛЭТУАЛЬ (letu.ru)](https://www.letu.ru), a large
Russian cosmetics and perfume retailer: search with pages, sorting, price
window, seller and in-stock filters; product cards with every variant priced
and the Club Card discount split out; delivery and store stock in your city;
per-product ratings and reviews.

Buyer side, anonymous: no ЛЭТУАЛЬ account needed, nothing is put in a cart.

## Why this server

The store's own API answers plain HTTP, but several of its numbers mean
something other than they seem. This server is built around not passing
those on unlabelled:

| Store trap | What this server does |
|---|---|
| The price shown to a visitor who is not logged in **already includes the Club Card discount**, which by the club rules applies only when the card is used (the card is free but needs registration with a phone number) | `price_rub` (as on the site) next to `price_without_club_card_rub`, computed from the site's own discount breakdown, with `club_card_discount_in_price` and the condition of every discount |
| The product-card API reports rating 0 and 0 reviews | Rating comes from the reviews service; `ratings_count` (оценки) and `reviews_with_text` (отзывы) separately; `scope` says it covers all variants |
| `isInStock` is global: a variant can be "in stock" and still not orderable in your city | `in_stock_anywhere` is labelled as global; availability in the city comes only from the delivery endpoint (`delivery_in_city`) and the store list |
| The search tile price is the cheapest variant **available in the city**; `minSkuPrice` is a pre-discount price | `price_from_rub` with the variant it belongs to (`price_from_sku_id`); `minSkuPrice` is never used as a price |
| Sponsored inserts in search ignore filters and sort order and duplicate items | `sponsored: true`, duplicates dropped |
| With a price window, the price sort pins some items at the top | `out_of_price_order` lists them; the site's order is kept, not silently re-sorted |
| Filters in a wrong format are silently ignored | Filter formats verified against the live site; tile prices outside the window are flagged; an unknown category URL is an error, not an empty result |
| `totalProducts` stops at 10 000 | `"≥10000"` with a note |
| Two cities named "Москва" (ids 8113 and 55109) with different delivery | The site's main (bold) entry is used, the others reported in `ambiguous_ids` |
| The price-breakdown endpoint shows a "best price" computed with a 2 500-point bonus balance even to anonymous visitors, and bonus amounts that differ from the page | Neither is passed on; bonuses come from the product card, as on the page |
| "From 2 pcs" kit prices do not reconcile with the unit price | Passed on as the site's text only |
| Cross-border sellers have OGRN "0000" | `ogrn: null`, `cross_border: true` and a risk note (authenticity not verified by the site, delivery takes weeks) |
| Promo codes are order-level conditions with a minimum order sum | Listed with `min_order_rub`; never subtracted from an item price |
| The review sort "rating ascending" is silently ignored by the site | Not offered; `max_rating` filters complaints instead |
| Delivery for an unknown SKU id returns HTTP 200 with empty blocks | Reported as an error, not as "not available" |

Every answer carries `fetched_at` (UTC) and the `city` it was computed for:
the name comes from the store's own answer (its city list or geo-IP), with the
source (`city` parameter, `LETU_CITY`, or geo-IP). The city id is sent with
every city-dependent request; search, card and delivery answers do not echo
it back, the store list does and is checked (a list for another city is an
error, not data).

## Tools

| Tool | What it returns |
|---|---|
| `search_products(query, category_url, page, sort, price_min, price_max, seller, in_stock_only, limit, city)` | Up to 70 items per page; sort `popular` / `price_asc` / `price_desc` / `rating` / `reviews` / `discount` / `newest`; `total_variants_found` (the site's "найдено N вариантов"), `total_products_found`, `has_more`. Per item: `price_from_rub` + variant, pre-discount price, discount and whether it is the club-card one, variants in the city, per-product rating and count, markers, `sponsored`, `cross_border` |
| `get_product(product, sku_id, include_delivery, include_promos, include_description, city)` | Every variant: `price_rub`, `price_without_club_card_rub`, list price, discounts with their kind, bonuses back, seller (legal entity, OGRN, cross-border), `delivery_in_city` (pickup stores today/later, courier date and cost, pick-up points) for up to 6 variants; product rating with star split; category path and URL; promo codes with minimum order and kit offers for one variant; optionally description, composition, characteristics, perfume notes and conformity documents |
| `get_reviews(product, page, limit, sort, min_rating, max_rating, with_media, city)` | Date, stars, text, pros, cons, likes, verified purchase, expert, shop answer, reviewer's skin type/age group; sort `relevant` / `newest` / `rating_desc` / `useful`; star and media filters. Buyer names and order numbers are never returned |
| `get_store_availability(sku_id, city)` | Stores in the city (ЛЭТУАЛЬ and partner «Подружка» shops) with the site's stock level (Мало / Средне / Много), pickup today or later and when; courier and pick-up point delivery |
| `compare_products(products, with_delivery, city)` | Up to 10 products side by side, one variant each (pinned, or the cheapest one orderable in the city) |

`product` accepts a product id (`71600124`), a product URL, or
`productId:skuId` to pin a variant.

## Requirements

Measured on 2026-09-25 (Linux, Python 3.12, Russian residential IP).

| | |
|---|---|
| Python | ≥ 3.10, with [uv](https://docs.astral.sh/uv/) (or pip) |
| Browser | **Not needed.** Plain HTTP; no Playwright, no Chromium, no display |
| Docker | Not needed |
| Disk | ≈ 35–40 MB for the Python environment (mcp, pydantic, requests, …) |
| Memory | Server process ≈ 75–82 MB RSS (peak 78 MB during a first search with a fresh session). When started through `uvx`, the `uv` launcher stays alive next to it: ≈ 100 MB RSS (up to ≈ 240 MB seen right after start) |
| Cold start | MCP `initialize` 0.4 s from an installed environment, 2.2 s through `uvx` with a cached build. First search on a fresh cache ≈ 6 s (session + city + search, spaced 2 s apart) |
| Per call | One HTTP request takes 0.1–0.9 s; the rest is the deliberate 2 s spacing. Search ≈ 2–5 s, reviews ≈ 2 s, store availability ≈ 5 s, `get_product` ≈ 25 s for a 4-variant product (10 requests), `compare_products` ≈ 11 s per product |
| Network | A Russian IP. Foreign and VPN addresses were not tested |

## Install

Claude Code:

```bash
claude mcp add letu -- uvx --from git+https://github.com/SZhukovWork/letu-mcp letu-mcp
```

Any MCP client (`claude_desktop_config.json`, `.mcp.json`, …):

```json
{
  "mcpServers": {
    "letu": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/SZhukovWork/letu-mcp", "letu-mcp"],
      "env": {"LETU_CITY": "Екатеринбург"}
    }
  }
}
```

From a checkout: `uv venv && uv pip install -e . && .venv/bin/letu-mcp`.

## Configuration (environment variables)

| Variable | Default | Meaning |
|---|---|---|
| `LETU_CITY` | the site's geo-IP city | City name as on letu.ru (`Екатеринбург`, `Москва`, …). Every tool also takes a `city` parameter |
| `LETU_MIN_INTERVAL` | `2.0` | Seconds between requests to letu.ru (plus up to 0.5 s jitter) |
| `LETU_PROXY` | — | Proxy URL, e.g. `http://user:pass@host:3128` |
| `LETU_CACHE_DIR` | `~/.cache/letu-mcp` | Where the anonymous session is kept (directory 0700, file 0600) |
| `LETU_USER_AGENT` | a current desktop Chrome UA | Override when the built-in one gets old (the site's firewall answers 403 to non-browser clients) |

## What the numbers mean

- **`price_rub`** — the price the site shows without login. When
  `club_card_discount_in_price` is `true` it already includes the ЛЭТУАЛЬ Club
  Card discount. **`price_without_club_card_rub`** is the price without it,
  from the site's own "applied discounts" breakdown. `discounts[]` lists every
  discount with its `kind` (`club_card`, `sale`, `payment_method`, `coupon`,
  `other`); `discount_conditions` explains each kind present. Sale discounts
  apply to everyone.
- **`price_list_rub`** — the crossed-out price before all discounts.
- **`price_from_rub`** (search) — the cheapest variant of the product that is
  available in the city, exactly as on the tile; confirm the variant with
  `get_product`. `club_card_discount_in_price` absent in search = discounted,
  but search does not say which discount.
- **`bonuses_back` / `bonuses_back_premium`** — points credited to a club
  member after purchase (with the paid Premium tier); not a discount.
- **`delivery_in_city`** — the only per-city availability signal:
  `orderable_in_city`, number of stores for pickup today / later, courier date
  and cost, pick-up points. `courier_cost_rub` is the site's price for this
  item alone; free delivery depends on the order sum. `0` means free, a missing
  value means unknown.
- **`stock_level`** — the site's word for a store's stock (Мало / Средне /
  Много); exact quantities are not published.
- **Ratings** — per product: all volumes and shades share one rating, and
  reviews do not say which variant was bought. `ratings_count` = оценки,
  `reviews_with_text` = отзывы. The `rating` search sort puts products with a
  handful of 5-star ratings first — look at `ratings_count`.
- **Promo codes** — conditions on the whole order with `min_order_rub`; they
  are not subtracted from an item price.

## Limitations

- Unofficial: relies on the storefront's internal JSON API, which ЛЭТУАЛЬ can
  change at any time. Parsing is isolated in `parse.py` and covered by tests
  on recorded responses.
- The NGENIX CDN in front of the site had a JavaScript proof-of-work challenge
  on this API in 2024; on 2026-09-25 it was off. If it comes back, tools
  return a clear `ChallengeRequired` error — this server does not solve it.
- Whether a buyer without the Club Card really pays `price_without_club_card_rub`
  at checkout was not verified in a cart (the club rules say the discount needs
  the card).
- Personal prices («Цены только для тебя») and the real bonus balance are shown
  only after login and are not available.
- The site has no price history, no barcode (EAN), manufacturer article or
  country of origin in its API; `article` is ЛЭТУАЛЬ's own product code.
- No questions & answers (the site's product "questionnaire" is a poll).
- `get_product` checks delivery for up to 6 variants and promo codes for one
  variant per call (pass `sku_id` for another one). Variants with identical
  price fields share one price-breakdown look-up, and the answer says so.
- `seller` must match the site's seller name exactly (`Лэтуаль`, `Подружка`, …);
  an unknown name returns no products. `in_stock_only` is the site's own
  "Есть в наличии" filter; the site does not say which warehouses it counts.
- Review `has_more` can be `true` while the next page is empty: the site counts
  ratings without text that the review list does not return.
- Calls are spaced 2 s apart; a comparison of 10 products takes about two minutes.

## Roadmap

- Optional account mode (personal prices, real bonus balance) and the cart — later.
- Browser fallback for the NGENIX challenge, if the site switches it on again.
- Checkout, payment and address changes are out of scope by design.

## Development

```bash
uv venv && uv pip install -e '.[dev]'
.venv/bin/pytest            # offline tests on recorded, anonymised responses
.venv/bin/pytest -m live    # end-to-end over MCP stdio against live letu.ru (Russian IP, ~30 requests)
```

## Disclaimer

Not affiliated with ЛЭТУАЛЬ. An unofficial server that uses the storefront's
internal API; intended for personal price research at a human request rate.
Respect the site's terms of use and keep request rates low.

License: MIT.

TDQS

A4.2/5.0

Scored across 5 tools

Disambiguation5/5

Each tool targets a distinct concern: search, product detail, reviews, store availability, and comparison. There is minimal overlap, and even the most similar tools (get_product vs compare_products) are clearly separated by purpose.

Naming Consistency5/5

All tool names follow a consistent snake_case verb_noun pattern: search_products, get_product, get_reviews, get_store_availability, compare_products. The naming is predictable and makes the tool's action and target easy to identify.

Tool Count5/5

Five tools is well-scoped for a retail product data server. Each tool covers a meaningful, non-redundant part of the shopping research flow without adding clutter or requiring excessive call chains.

Completeness4/5

The read-only product surface is well covered: search, full product details, reviews, store pickup, and comparison. Minor gaps exist such as no dedicated category/brand listing or bulk variant availability tool, but agents can accomplish the core workflows using the provided tools.

Maintenance

ActivityMaintained
ResponsivenessNo issues