У Яндекс Лавки нет публичного 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, а часть перетирает друг друга. Фикс:
-
Сериализовать записи в корзину внутри процесса — общий
asyncio.Lock, чтобы параллельные вызовы не толкались. -
На 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/