Как я научил Claude заказывать продукты в Яндекс Лавке (и что для этого пришлось отреверсить)

от автора

У Яндекс Лавки нет публичного API. А мне захотелось написать в чат «закажи молоко, хлеб и что-нибудь к ужину» — и чтобы заказ реально уехал курьеру. В итоге получился MCP-сервер, через который ассистент ищет товары, собирает корзину и оформляет заказ, а я по дороге вычистил кучу неочевидных граблей: CSRF-заголовки, гонки за общую корзину, «фантомные» товары из другого склада и пустой способ оплаты. Ниже — как это устроено и где меня били по рукам.

Сразу дисклеймер: проект неофициальный, он ходит в тот же приватный веб-API, что и lavka.yandex.ru, под твоей собственной сессией. Это может нарушать ToS Яндекса, API в любой момент могут поменять или прикрыть, а оформление заказа тратит реальные деньги. Всё на свой страх и риск. Код открыт под MIT: github.com/Dudude-bit/yandex-lavka-mcp.

Что такое MCP и зачем он тут

MCP (Model Context Protocol) — это стандарт, по которому ИИ-ассистент (Claude, и не только) подключает внешние «инструменты». Ты описываешь набор функций — search_products, add_to_cart, checkout_preview — и модель вызывает их сама, когда это нужно по ходу диалога. То есть моя задача сводилась к двум вещам: (1) научиться разговаривать с бэкендом Лавки и (2) завернуть это в аккуратные инструменты, которые не дадут модели натворить дел с моими деньгами.

Реверс: где вообще у Лавки API

Первым делом — открыть lavka.yandex.ru в залогиненном Chrome и посмотреть, что фронт шлёт в сеть. Всё интересное живёт под одним префиксом:

https://lavka.yandex.ru/api/v1/providers/*

Пробежавшись по вкладке Network и подсадив в страницу перехватчик fetch, я вытащил реальные эндпоинты:

  • поиск — POST /api/v1/providers/search/v3/lavka

  • карточка товара — POST /api/v1/providers/v1/product

  • корзина — POST /api/v1/providers/cart/v1/retrieve и .../cart/v1/update

  • оформление — POST /api/v1/orders/submit (внезапно не под /providers/)

  • карты — POST /api/v1/providers/payments/v1/methods

  • адреса и гео — .../address/v1/get-favorite-addresses, .../geo/v1/suggest, .../geo/v1/geocode

Тела запросов и структуры ответов я снимал прямо с живого трафика — так надёжнее, чем гадать.

Грабля №1: 401, хотя куки на месте

Первый же запрос из Python отдал 401. Куки сессии (Session_id и компания) в запросе были — но Лавка всё равно отвечала «не авторизован».

Оказалось, POST-запросам нужен анти-CSRF-токен плюс набор заголовков, которые фронт добавляет сам:

X-CSRF-Token: <...>X-Lavka-Web-City: 213      # регион (213 = Москва)X-Lavka-Web-Locale: ru-RUX-Captcha-Service: lavkaX-Requested-With: XMLHttpRequest

А сам CSRF-токен зашит прямо в HTML главной страницы, в JSON-блобе:

<script id="__page_props__-data" type="application/json">{"csrfToken":"...","...":...}</script>

Так что клиент при старте просто ходит на главную, регуляркой вытаскивает csrfToken, кэширует его и подставляет в заголовки. А если прилетел 401 — один раз обновляет токен и повторяет запрос (вдруг протух):

_CSRF_RE = re.compile(r'"csrfToken"\s*:\s*"([^"]+)"')async def _ensure_csrf(self, *, force=False):    if self._csrf_token and not force:        return    resp = await self._client.get("/")    m = _CSRF_RE.search(resp.text)    if m:        self._csrf_token = m.group(1)

После этого read-путь (поиск, каталог, корзина) завёлся с первого раза.

Данные Лавки: почему нельзя просто отдать ответ модели

Ответ поиска — это мегабайты. Товары лежат в поле cacheProducts, и у каждого — десятки полей. Если скормить это модели как есть, сгорит весь контекст (и деньги на токенах). Поэтому каждый ответ я «обрезаю» до нужного:

{  "id": "...",            # хэш — им добавляем в корзину  "slug": "...",          # deepLink — им открываем карточку  "title": "Молоко 2,5%",  "price": 99.0,  "old_price": 109.0,  "quantity_label": "930 мл",  "in_stock": True,}

Тут первый неочевидный момент: у товара два идентификатора. В корзину он кладётся по хэшу (id), а карточка открывается по слагу (deepLink). Перепутаешь — получишь 404 или «товар не найден».

Money-safety: двухшаговое оформление

Это же реальные деньги. Модель может ошибиться, «нафантазировать» сумму или зациклиться. Поэтому оформление разбито на два инструмента:

  • checkout_preview — считает итог (товары, скидка, доставка, ETA, карта) и не списывает ничего.

  • confirm_order(confirmed_total) — собственно оформляет. И он откажется, если:

    • превью не делали или оно протухло (TTL);

    • переданная сумма не совпадает с показанной в превью;

    • живая корзина «уплыла» с момента превью (изменилась версия или итог) — тогда деньги не спишутся, надо перепревьюить.

Ключевая идея: перед списанием клиент перечитывает корзину и сверяет её версию и сумму с подтверждёнными. Если между «покажи итог» и «оплачивай» что-то поменялось (цена, промо, наличие) — заказ не уходит.

if expected_cart_version is not None and live_version != expected_cart_version:    raise LavkaApiError("Корзина изменилась с момента превью — переоформи.")if abs(live_total - confirmed_total) > 0.01:    raise LavkaApiError("Сумма изменилась — переоформи.")

Война с багами (самое интересное)

Дальше начались настоящие приключения — и почти каждый баг я ловил не «на глаз», а воспроизведением.

Гонка за общую корзину (HTTP 409)

Как-то ассистент собирал корзину, а в ней творился хаос: версия скакала с 20 до 39, половина позиций пропадала, появлялись товары, которые я не добавлял. Легко было списать на «ну глюк». Но факты сказали другое.

Корзина Лавки — это один общий серверный объект на аккаунт, с оптимистичной блокировкой по cartVersion. Я это доказал двумя опытами: чистое чтение корзины версию не двигает, а два одновременных add_to_cart дают:

S1 добавил молоко -> cartVersion 6→7S2 (с устаревшей версией 6) -> HTTP 409 Conflict

То есть модель шлёт несколько добавлений разом (Claude умеет батчить вызовы инструментов), они гонятся за одну корзину, и часть падает с 409, а часть перетирает друг друга. Фикс:

  1. Сериализовать записи в корзину внутри процесса — общий asyncio.Lock, чтобы параллельные вызовы не толкались.

  2. На 409 — перечитать свежую версию и повторить.

async def _cart_mutate(self, build_items):    async with _CART_WRITE_LOCK:        for attempt in range(_CART_CONFLICT_RETRIES + 1):            cart = await self._get_cart_raw()            items = build_items(cart)          # пересчёт от свежей корзины            try:                return self._normalize_cart(                    await self._call("cart_update", self._cart_write_body(items, cart)))            except LavkaApiError as exc:                if exc.status == 409 and attempt < _CART_CONFLICT_RETRIES:                    continue                    # уплыла версия — читаем заново                raise

После этого два одновременных добавления спокойно встают в очередь, и оба оказываются в корзине.

«Раскупили» и «только из Большой Лавки»

Следующий сюрприз: часть товаров в корзине была серой — «только из Большой Лавки», а часть отвалилась с «Раскупили». Причём курица и свинина «раскупились» за минуту — что для крупного московского склада странно.

Проверил живьём: та самая «раскупленная» курица в поиске — available: true, лимит 40 штук. То есть это не сток-аут. Лавка держит два склада: «лавка у дома» (regular) и «большая лавка» (supermarket). Поиск в «лавке у дома» отдаёт товары из большого склада как доступные, без пометки склада. Модель их добавляет — а корзина потом помечает:

{ "isUnavailableOnDepot": true }         // на уровне товара{ "availableForCheckout": false }        // на уровне корзины

Беда была в том, что мой клиент эти флаги выбрасывал, и модель не знала, что корзина неоформляема. Починка: пробрасывать флаги наверх и, главное, отдавать модели человекочитаемый warning — не голый булев, а текст, на который она среагирует:

«Эти товары нельзя заказать из текущей лавки (только Большая Лавка / раскуплены): …. Убери или замени перед оформлением».

Плюс confirm_order теперь отказывает, если availableForCheckout: false — за кривой набор деньги не спишутся. А add_to_cart проверяет, что товар реально лёг в корзину, и если Лавка его молча выкинула — сразу пишет об этом.

Мораль: если у API есть флаг «неоформляемо» — доставай его и переводи в понятную модели фразу, а не надейся, что она сама догадается по имени поля.

Пустой способ оплаты

Агент однажды заметил: в превью payment_method: null. Хорошо, что заметил — иначе заказ ушёл бы в submit с пустой картой и не прошёл.

Причина: cart.paymentMethod заполняется только после явного выбора карты (эндпоинт set-payment, который дёргает веб-чекаут). Мой клиент его не звал. Нашёл эндпоинт списка карт (тело {location, countryIso3:"RUS"} — обязательное поле там countryIso3), и теперь превью и заказ сами резолвят карту: выбранная в конфиге → карта на корзине → карта аккаунта по умолчанию. А если карты нет вообще — place_order отказывается оформлять. Заодно появились инструменты «показать карты» и «выбрать карту».

Заказать с телефона: удалённый деплой с OAuth

Локально MCP работает по stdio. Но хотелось заказывать прямо из мобильного приложения ассистента — а туда подключаются удалённые MCP через кастомный коннектор. Тут выяснилось важное: коннектор в вебе/мобилке умеет только OAuth (статичный bearer-токен — это про десктоп). Так что публичный эндпоинт надо закрывать полноценным OAuth.

Сервер я сделал провайдеро-независимым OAuth-ресурсом: он проверяет JWT против JWKS любого OIDC-провайдера (issuer/audience/scopes — через env) и отдаёт метаданные protected-resource, чтобы коннектор сам нашёл авторизацию и провёл вход. Транспорт переключается одной переменной:

YANDEX_LAVKA_MCP_TRANSPORT=streamable-httpYANDEX_LAVKA_MCP_OAUTH_ISSUER=https://<твой-oidc-провайдер>YANDEX_LAVKA_MCP_OAUTH_AUDIENCE=<...>

И отдельная страховка: раз эндпоинт тратит деньги, сервер отказывается стартовать как публичный HTTP без настроенного OAuth. Плюс allow-list по sub — чтобы сервером мог пользоваться только ты, а не любой, кто узнал URL.

Куки Лавки на сервере живут как секрет (одной переменной, не в образе). Минус, который честно признаю: сессия Яндекса протухает, а залогиниться headless нельзя (2FA/капча) — так что раз в сколько-то времени куку надо пересобирать руками.

Что в итоге

Получился MCP-сервер на Python (FastMCP + httpx) с ~17 инструментами: поиск, карточка, корзина, адреса (в том числе выбор города по тексту через геокодер Лавки), карты, двухшаговое оформление, отслеживание заказа. Read-путь и сборка корзины проверены на живом API; финальное списание — на реальном заказе.

Главные уроки:

  • Реверси на живом трафике, а не по догадкам: точные тела запросов экономят часы.

  • Обрезай ответы — сырой payload убивает контекст модели.

  • С деньгами — fail closed: перечитывай состояние перед списанием, сверяй сумму и версию.

  • Отдавай модели человекочитаемые предупреждения, а не сырые флаги.

  • Общий серверный ресурс + параллельные вызовы модели = гонки. Лок + retry на конфликт версии.

  • Дебажь воспроизведением, а не «на глаз» — «раскупили за минуту» оказалось совсем не тем, чем выглядело.

Код: github.com/Dudude-bit/yandex-lavka-mcp. Ставится через uvx yandex-lavka-mcp. Буду рад звёздам, issue и историям, что у вас поменялось в приватном API Лавки.

ссылка на оригинал статьи https://habr.com/ru/articles/1062042/