Как я научил Claude заказывать продукты в Яндекс Лавке (и что для этого пришлось отреверсить). API.. API. Claude.. API. Claude. llm.. API. Claude. llm. mcp.. API. Claude. llm. mcp. model context protocol.. API. Claude. llm. mcp. model context protocol. oauth.. API. Claude. llm. mcp. model context protocol. oauth. Open source.. API. Claude. llm. mcp. model context protocol. oauth. Open source. python.. API. Claude. llm. mcp. model context protocol. oauth. Open source. python. автоматизация.. API. Claude. llm. mcp. model context protocol. oauth. Open source. python. автоматизация. искусственный интеллект.. API. Claude. llm. mcp. model context protocol. oauth. Open source. python. автоматизация. искусственный интеллект. Проектирование API.. API. Claude. llm. mcp. model context protocol. oauth. Open source. python. автоматизация. искусственный интеллект. Проектирование API. Реверс-инжиниринг.

У Яндекс Лавки нет публичного 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-RU
X-Captcha-Service: lavka
X-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→7
S2 (с устаревшей версией 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-http
YANDEX_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 Лавки.

Автор: kirillinyakin

Источник