Skip to content
Conduit

mcp server

ozon-mcp

MCP server for the ozon.ru buyer account: orders, purchase history, cart, favorites, product cards and catalog search over the internal composer-api

Alexander-Zhukov/ozon-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "ozon-mcp": {
      "command": "uvx",
      "args": [
        "ozon-mcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 25, 2026

English · Русский

Ozon MCP

An MCP server for one ozon.ru buyer account. It reads orders, purchases, returns, the cart, favorites, wishlists, «Подборки», product cards, catalog search, the Ozon Card balance and points — and, when explicitly allowed, changes the cart and the lists and runs an order: forming a checkout, placing it, paying, cancelling it item by item.

None of this has a public API. The server talks to the same internal composer-api / entrypoint-api endpoints the site's own frontend uses, under your authenticated session.

Requirements

  • A Russian IP. Ozon blocks datacenter and VPN egress.
  • A real Chromium. Headless is detected; the image runs it under Xvfb.
  • One interactive login, performed once against the profile directory.
  • --shm-size=1g. Chromium crashes on Docker's default 64 MB.

How it works

The Variti antibot blocks non-browser clients, but a browser only has to pass the challenge once. A real Chromium starts, passes it, and hands over its cookies and the exact client-hint header set; from then on every read and write is a direct HTTP call through curl_cffi with Chrome's TLS fingerprint. No tool renders a page, which is why a call takes about 0.2 s instead of 15. The browser stays only to own the profile, refresh the session, and serve as the fallback for one reading — the delivery estimate.

The session lives in a persistent Chromium profile, not a cookie snapshot. OzonID, the auth realm guarding checkout, keeps its state outside cookies and localStorage, so a Playwright storage_state snapshot silently loses it and checkout falls back to a login prompt. A profile directory keeps everything, so one login stays valid and the server runs unattended.

Playwright's sync API cannot run inside a live event loop, and its objects belong to the thread that created them, so tool calls are dispatched to a single worker thread. They are therefore serialised, and a slow one — a bootstrap, a login — makes the others wait.

Tools

Orders and returns

Tool
list_ordersOrders (active / archive / all), each with its order_number, status, state, pickup point, slot, what is owed on collection and per-item payment state; optionally within an ISO date range
order_productsItems of one order: sku, title, price paid, variant, seller, and each item's parcel status
purchasesEverything ever ordered, as product tiles; with a query, Ozon's own search over the whole history
bought_itemsWhat was actually received: the purchase list matched against the orders, with the coverage of the scan
list_returnsReturns: number, date, status, amount, the products going back

Catalog

Tool
searchStorefront search by text and/or category slug, with sort, facet filters and a depth limit
get_search_filtersFacets available for a query, and the values search takes
product_detailsCard: price, variants (each with its own sku), characteristics, photos; description and reviews on request
get_reviewsRating, review count, the breakdown per star, and reviews to any depth (sort="worst" for the complaints)
get_descriptionDescription text plus the images embedded in it
delivery_estimateWhen a product would arrive, to which address, from which warehouse
find_cheaperThe cheapest lots of the same thing: Ozon's own other-seller offers plus a price-sorted search

Cart

Tool
get_cartThe whole cart: items, quantities, ticks, group headings, and the size Ozon declares
set_cart_quantitySet the quantity; 0 removes
select_cart_itemsTick what makes up the order — only / add / remove / all / none

Favorites and wishlists

Tool
list_favoritesFavorites as product tiles
set_favoriteAdd or remove
get_listsWishlists with their ids and sizes; with a sku, whether each holds it
create_list / delete_listMake a wishlist, or delete one
set_list_membershipPut a product in a wishlist or take it out
check_favorite_price_dropsPrice changes since the previous call

«Подборки»

Tool
list_selectionsSelections with size, status, ids and the share link
get_selectionOne selection in full, including description and visibility
selection_productsWhat it holds: sku, title, price, card link
create_selectionCreate one around a product; private by default
add_to_selectionAdd products, keeping the ones already in it
remove_from_selectionTake products out, keeping the rest
set_selection_itemsReplace the whole list of products
edit_selectionRename it, replace its description
set_selection_publicPublish to the public profile, or unpublish
delete_selectionDelete it; the products stay

Checkout

Tool
get_checkoutThe order being formed: payment methods, pay-on-delivery, destinations, pickup points, shipments, points, totals
configure_checkoutSet payment, points, pay-on-delivery and pickup point in one call
place_orderSpends money. Submit, waiting until the order exists
pay_orderCharge an order left unpaid, and report what is left to do
list_cancel_reasonsReasons Ozon accepts for cancelling
cancel_orderCancel an order, or named items of it, returning them to the cart

Session and money

Tool
session_statusWhether the session acts as the account, and which gates are open
start_login / submit_login_codeRestore a dead session with a one-time code
get_financesOzon Card balance and total points
get_pointsPoints by type, burning points, per-store seller bonuses

Changing the account is off by default. OZON_ENABLE_WRITES=1 allows the cart, favorites, lists and cancellations; OZON_ENABLE_ORDERS=1 allows place_order and is separate because it spends money. session_status reports both, so a caller can plan around them instead of finding out halfway through.

What a caller needs to know

Some of Ozon's behaviour does not follow from a tool's name.

The cart ticks are the order. get_checkout reports nothing orderable until something is ticked, and it orders exactly what is ticked — so "buy these two" is select_cart_items(skus, mode="only"), whatever else the cart holds. Ozon's checkout is a snapshot of those ticks taken when checkout is entered, and only an entry replaces it: unticking an item afterwards changes the cart and not the order. get_checkout and place_order therefore enter checkout before reading, which keeps the options already chosen.

Two money figures, never interchangeable. totals.total is what Ozon charges today; totals.order_total is what the order costs. On a pay-on-delivery order today's charge is 0 ₽, so order_total is the figure to quote to a person.

An order row has no total. Ozon prints none on the order list, so a row carries amount_due_at_pickup («К оплате при получении») and, per item, that item's price and paid. The sum owed on collection is not any item's price, and paid is null when Ozon says nothing — which is not unpaid.

«Текущие заказы» is not a list of current orders. Ozon keeps the recently received and cancelled ones on that page. scope="active" leaves them out; state says which of the three a row is.

Pay-on-delivery is not all-or-nothing. Ozon defers payment per shipment, so an order can be part deferred and part prepaid — an imported item usually has to be paid up front. pay_after_receipt.scope is full, partial or none; on a partial order pay_now_items and pay_on_receipt_items name the lines on each side, as Ozon splits them.

«Купленные товары» is a list of products, not of outcomes. It holds everything ever ordered — including what was refused at the pickup point — and states no order, no date and no status; its price is today's catalogue price, not what was paid. On the account this was built against, 17 of 29 items matching one query had been ordered and never taken home. The outcome lives in the orders: an order is delivered in parcels with separate fates, so order_products reports shipment_status and received per item, and bought_items matches the list against the orders to fill them in, and its answer states how far it looked: each order costs a request, so a bounded scan is partial and says so — unresolved is "never seen in the orders scanned", provisional is "seen only as cancelled, an older order may have been received".

An order's dates are status dates. The archive prints only «Получен 11 августа» / «Отменён 12 февраля 2021», so a date range filters on when an order was received or cancelled — an order placed on 30 June and received on 2 July lands in July. The archive is a newest-first cursor, so a window is walked down to: the cost is the depth, not the width. A recent fortnight comes back in half a second; a month two years back takes tens of seconds.

A rating is a number Ozon states. product_details carries rating, reviews_count and questions_count; get_reviews adds the per-star breakdown and walks pages to the depth asked for, reporting count (the product's total) beside fetched (what came back) — they are different numbers. Ozon offers no filter by star, so the one-star reviews are reached with sort="worst" and depth. A card's reviews cover its variants, so each review names the size or colour it is about, and «Достоинства» / «Недостатки» stay apart from the comment.

Three prices, and only one of them is charged. A card states price («С банками» — what the account pays), price_regular («С другими банками») and price_old (struck through). Comparing lots on anything but the first calls a lot cheaper than it is, so search(sort="cheap") and find_cheaper rank on it.

Ozon's text search is literal. A lot whose own title omits the brand is not returned for a query that includes it — and when nothing matches exactly Ozon fills the page with "you might also like", which is dropped rather than passed off as results. So search the model, not brand-plus-model, and confirm what a lot is with product_details: a tile's title is the seller's wording and may name no model at all. limit is depth — pages are walked until it is met, because the cheapest lot is regularly not on the first one.

Wishlists and «Подборки» are different things. A wishlist has a numeric id and holds anything. A selection has a uuid, a cover, a description and a visibility; its products come from favorites only, and publishing puts it on the account owner's public profile after review. Its products are a separate read — the list states a count — and Ozon has no "add" of its own: the form submits the whole list, which is what add_to_selection / remove_from_selection do around the current contents.

Cancelling works line by line. cancel_order(order, skus=[...]) drops those items and leaves the rest of the order standing, in as many passes as it has items.

A card charge finishes on Ozon's bank domain, which signs the account in to the bank. pay_order drives the payment to that point and reports what remains: the amount, how much to top the card up by, and the page to finish at. Pay-on-delivery avoids the whole thing.

Errors carry a code as well as a sentence — [rate_limited], [session_expired], [writes_disabled], [orders_disabled], [total_mismatch], [upstream_unavailable]. Branch on the code; the sentence is written to be relayed to a person. A failure never arrives as an empty result: a 502 or a timeout raises, because "no orders" and "Ozon did not answer" are different answers.

Configuration

VariableDefault
OZON_PROFILE_DIR/data/profilePersistent Chromium profile holding the session
OZON_PROFILE_BACKUP/data/profile.backupCopy of a known-good profile, restored on a sign-out
OZON_STATE_PATH/data/state.jsonLegacy cookie snapshot, imported once to seed a new profile
OZON_IMPERSONATEchrome124curl_cffi TLS-impersonation profile
OZON_ENABLE_WRITES0Allow cart / favorites / list / cancellation changes
OZON_ENABLE_ORDERS0Allow place_order — spends money
OZON_MONITOR_STORE/data/price_history.jsonFavorites price-history file
OZON_REQUEST_TIMEOUT30Seconds for one HTTP call
OZON_REQUEST_ATTEMPTS3Attempts before a call is reported as failed
OZON_RETRY_BACKOFF_SECONDS1Base wait between attempts, doubled and jittered
OZON_RETRY_CAP_SECONDS20Longest wait, including a Retry-After Ozon asks for
OZON_BROWSER_TIMEOUT60Seconds for a browser navigation or a login step
OZON_IDLE_SECONDS600Close the idle browser; the HTTP session stays
OZON_TRANSPORTstdiostdio (client spawns the process), http (streamable HTTP at /mcp) or sse (deprecated, /sse)
OZON_HOST / OZON_PORT0.0.0.0 / 8084Bind for the HTTP transports; the same port serves /metrics
OZON_ALLOWED_HOSTS[]Host headers the HTTP transports (http and sse) accept; empty means any. Naming hosts turns on MCP's exact-match check

See env.example.

Running

docker build -t ozon-mcp .

# stdio: the client attaches to the container's stdin/stdout
docker run -i --rm --shm-size=1g -v /opt/ozon-mcp:/data ozon-mcp

# http: long-lived service on :8084 (/mcp + /metrics)
docker run -d --name ozon-mcp --shm-size=1g -v /opt/ozon-mcp:/data \
  -e OZON_TRANSPORT=http -p 8084:8084 ozon-mcp

/data has to be a bind mount. It holds the profile, and Ozon rotates the session constantly: cookies rotated over HTTP are pushed back into the profile so the refresh chain survives a restart. On an ephemeral directory the login is lost on every container recreation.

The profile is a credential. It grants full access to the account — orders, addresses, card balance, checkout. Never commit it; back it up encrypted.

Locally, with uv:

uv sync
uv run playwright install --with-deps chromium
uv run python -m ozon_mcp   # needs a display or Xvfb

Under http and sse the same port serves Prometheus metrics at /metrics: upstream request outcomes and latency, antibot re-challenges, session bootstraps, browser state.

Session lifetime

A signed-out session looks exactly like an empty account — no orders, no balances, no explanation — so every tool raises instead of answering, session_status reports the state, and start_login / submit_login_code restore it with a one-time code that only the account owner receives.

A profile known to be signed in is copied, and a sign-out is recovered from that copy automatically, so in most cases nobody is asked for a code.

Several agents at once

Multiple clients can connect over the HTTP transports, and each gets its own MCP session. They share one Ozon account and one worker thread, which has two consequences.

Calls are serialised: a second agent waits out the first, including a 13-second browser bootstrap.

More importantly, the account state is shared. Cart ticks live on Ozon's side, not in the MCP session, so two agents composing orders at once overwrite each other's selection — and place_order buys whatever is ticked at that moment, at a total the caller read a second earlier. Reads are safe for any number of agents; writes are not. Keep one writer per account.

The endpoint has no authentication: whoever reaches the port has the account.

Limitations

  • Only saved addresses can be chosen. configure_checkout switches between the points in the account's address book; adding one from the map is not implemented.
  • A selection's cover cannot be uploaded; that is a multipart upload and is not covered.
  • Per-shipment destinations are handled but unverified against a live order Ozon actually split across addresses.
  • pay_order cannot complete a card charge: that happens on Ozon's bank domain and needs banking credentials this server does not hold.
  • Placing an order spends real money, and is gated separately from every other write.

Development

make check-all   # ruff format --check, ruff check, ty, pytest with a coverage gate

ty runs with all = "error", ruff with select = ["ALL"] and a curated ignore list. Tests mirror src/; the service layer is driven through a stand-in for get_session() and the transport through a stand-in for the HTTP session, so nothing in the suite touches the network.

Disclaimer

Scraping a personal account is against Ozon's Terms of Service. This project is for personal, low-volume use, and you are responsible for how you use it.

License

MIT

↑↓ navigate openesc close