Как дать LLM-агенту доступ к Яндекс Вебмастеру: разбираем устройство MCP-сервера над чужим API

от автора

Данные о том, как поиск видит ваш сайт, живут в двух местах: в интерфейсе Яндекс Вебмастера и в его API. Чтобы ответить на обычный рабочий вопрос — «по каким запросам нас показывают много, а кликают мало», «что выпало из индекса за месяц», «что Яндекс считает проблемой прямо сейчас» — человек ходит по вкладкам и сводит цифры руками, а программа собирает запросы к API v4, помнит про формат идентификатора сайта, про повторяющиеся параметры и про то, что почти каждый ответ надо потом сопоставить с поведением людей из Метрики. LLM-агент по умолчанию не умеет ни того, ни другого.

В этой статье я разберу, как устроен MCP-сервер для Яндекс Вебмастера: как свести пару десятков эндпоинтов к восьми инструментам, которыми модель может пользоваться не наугад, какие грабли у API v4 и — самое интересное — как сделать вход в Яндекс, который агент выполняет сам, без терминала и без клиентского секрета. Код — на TypeScript; сам сервер open-source под MIT, ссылка в конце.

Материал будет полезен, если вы пишете свой MCP-сервер над чужим HTTP-API, разбираетесь с OAuth-флоу Яндекс ID или просто хотите понять, что происходит под капотом, когда агент «сам ходит» в поисковую консоль.

Что Вебмастер знает такого, чего нет в Метрике

Разделение простое: Метрика знает, что человек делал после клика, Вебмастер — всё, что происходит до. Через API v4 доступно:

  • поисковые запросы: показы, клики, средняя позиция показа и клика, с разбивкой по типам устройств;

  • индексация: что робот обошёл, с какими кодами ответа, что сейчас в поиске, а что исключено;

  • диагностика: проблемы, которые Яндекс нашёл на сайте, от ошибок DNS до robots.txt;

  • файлы Sitemap, которые Яндекс знает, с числом URL и ошибок;

  • внешние ссылки: примеры и динамика количества;

  • и единственная операция записи — отправить URL на переобход, с дневной квотой на сайт.

Ценность для агента именно в стыке двух источников. «Какие запросы дают много показов и мало кликов» — это Вебмастер. «На каких из этих страниц люди уходят, не дойдя до цели» — уже Метрика. Живой вопрос звучит как оба сразу, и если оба сервера подключены, агент сам сходит туда и туда, а не требует от вас разложить задачу на два.

Восемь инструментов вместо двух десятков эндпоинтов

Ключевое проектное решение то же, к которому я пришёл в сервере для Метрики: инструментов должно быть мало и они должны быть гибкими. Модель выбирает инструмент по имени и описанию, и чем длиннее список почти одинаковых имён, тем чаще она выбирает наугад. Поэтому эндпоинты сгруппированы не по принципу «один вызов — один инструмент», а по принципу «один вопрос — один инструмент»:

  • get_hosts — список сайтов, доступных токену, с их идентификаторами, состоянием подтверждения и, по запросу, сводкой по сайту (ИКС, страницы в поиске и исключённые, активные проблемы);

  • search_queries — аналитика поисковых запросов: report="top" даёт ранжированный список запросов, report="trend" — динамику по конкретному запросу или по сайту целиком;

  • get_indexing — обход и индексирование: history — динамика по классам HTTP-кодов, crawled — примеры обойдённых URL, in_search — примеры страниц, которые сейчас в поиске;

  • get_diagnostics — активные проблемы сайта, отсортированные по тяжести;

  • list_sitemaps — файлы Sitemap с числом URL и ошибок;

  • get_external_links — внешние ссылки: примеры или динамика;

  • recrawl_status — остаток дневной квоты и состояние задач на переобход;

  • recrawl_submit — единственный инструмент, который что-то меняет.

Три решения внутри этого списка стоят отдельного объяснения.

get_hosts — обязательный инструмент обнаружения, а не удобство. Идентификатор сайта в Вебмастере выглядит как https:example.com:443 — схема, хост и порт через двоеточие, без слэшей. Угадать его из адреса сайта нельзя, поэтому модели нужен способ получить список идентификаторов, и в описании инструмента прямо написано «вызови меня первым». Без этого модель начнёт конструировать host_id сама и получит 404 на каждом вызове.

Параметр report вместо трёх отдельных инструментов. У поисковых запросов три эндпоинта: топ запросов, история по одному запросу и агрегированная история по сайту. Для модели это одно понятие «поисковые запросы» с двумя режимами: top и trend (во втором — с необязательным queryId). Одна сущность в списке инструментов вместо трёх похожих имён, между которыми надо выбирать.

Ответ пересобирается, а не пересылается. Диагностика приходит словарём всех типов проблем, включая давно решённые, — отдавать его модели целиком незачем. Сервер оставляет активные, сортирует по тяжести и добавляет счётчики:

{  "host_id": "https:example.com:443",  "active_problems": [    { "problem_type": "DISALLOWED_IN_ROBOTS",   "severity": "FATAL",            "since": "2026-07-18T09:12:00+03:00" },    { "problem_type": "SLOW_AVG_RESPONSE_TIME", "severity": "CRITICAL",         "since": "2026-07-11T14:03:00+03:00" },    { "problem_type": "NO_SITEMAPS",            "severity": "POSSIBLE_PROBLEM", "since": "2026-06-30T08:40:00+03:00" }  ],  "counts": { "active": 3, "by_severity": { "FATAL": 1, "CRITICAL": 1, "POSSIBLE_PROBLEM": 1 } }}

Тот же принцип работает и в обратную сторону: recrawl_status возвращает остаток квоты вместе со списком задач, потому что модель должна видеть цену действия до того, как потратит последнюю единицу дневного лимита на URL, названный вскользь.

Кстати, в справочнике типов проблем есть NO_METRIKA_COUNTER и NO_METRIKA_COUNTER_BINDING: Вебмастер считает отсутствие счётчика Метрики проблемой сайта. Связка двух сервисов, ради которой всё затевалось, в каком-то смысле предусмотрена самим вендором.

Разбор API Вебмастера: то, чего нет в кратком гайде

API v4 документирован лучше, чем можно было ожидать, но несколько вещей всплывают только на практике.

Всё начинается с user_id. Пути выглядят как /v4/user/{user_id}/hosts/{host_id}/…, а числовой user_id надо сначала получить отдельным запросом к /v4/user. То есть любой первый вызов — это два запроса, и результат просится в кэш (в конце статьи будет история о том, как этот кэш ломает вход).

Массивы передаются повторяющимися ключами. query_indicator=TOTAL_SHOWS&query_indicator=TOTAL_CLICKS, а не CSV в одном параметре, как в Метрике. Ошибка тихая: параметр просто игнорируется, а вы смотрите на пустые индикаторы и ищете проблему не там.

if (Array.isArray(value)) {    for (const v of value) sp.append(key, String(v))} else {    sp.set(key, String(value))}

403 — это не 401. Оба статуса выглядят как «доступ закрыт», но лечатся противоположным. 401 — токен протух, нужен повторный вход. 403 — токен живой, но прав на этот сайт нет: он не подтверждён в Вебмастере под этой учётной записью. Для агента разница принципиальна: если на 403 ответить «переавторизуйтесь», модель добросовестно зациклится на входах. Поэтому каждая ошибка API отдаётся модели вместе со следующим шагом:

if (err.status === 403) {    return 'Access denied: the token lacks rights to this host. Verify the host in ' +           'Yandex Webmaster with this account, or call a hostId you can read.'}

Форма ответа отличается у соседних эндпоинтов. Топ запросов приходит объектом с массивом queries, а история одного запроса — тем же объектом, но без обёртки, сразу на верхнем уровне. Зато индикаторы отдаются словарём ({ TOTAL_SHOWS: [...], TOTAL_CLICKS: [...] }), а не позиционным массивом, как в Метрике: такой ответ читается сам по себе, без сверки с порядком параметров запроса. Разбирать схемы приходится по каждому эндпоинту отдельно — общего конверта у API нет.

Остальное собрал под спойлер — если будете писать свой клиент, сэкономит время.

Ещё грабли, которые лучше знать заранее

Конверт ошибки — свой. Вебмастер отвечает { error_code, error_message }, тогда как Метрика — { errors: [{ error_type, message }] }. Один вендор, соседние сервисы, разные форматы: разбор ошибок общим кодом не сделаешь.

Заголовок авторизации — OAuth, а не Bearer. Общая черта Яндекса: Authorization: OAuth <token>. Поставите по привычке Bearer — получите 403 и не сразу поймёте почему.

Троттлинг ловится по нескольким признакам. Помимо 429 у Яндекса встречается 420, а часть лимитов проявляется в коде ошибки, а не в статусе. Считать троттлингом стоит оба статуса плюс коды с QUOTA/LIMIT, и делать собственный экспоненциальный backoff.

Переобход — POST с JSON-телом, в отличие от всех читающих эндпоинтов. Мелочь, но клиент, написанный «только под GET с query-параметрами», придётся дописывать.

Пустой ответ — не ошибка. Пустое тело с 2xx (например, у DELETE) корректнее отдавать как «нет содержимого», а не пытаться разобрать как JSON и падать на непонятной ошибке парсера.

Авторизация: вход, который агент выполняет сам

Самая нетривиальная часть сервера — не запросы, а первый запуск. Классическая схема «токен из переменной окружения, иначе падаем» для CI правильная, а для человека, поставившего сервер кнопкой из каталога расширений, — тупик: процесс умер, стандартный вывод клиент не показывает, объяснить, что надо зарегистрировать OAuth-приложение, уже некому.

Значит, нужен интерактивный вход. Секрет в пакет положить нельзя — всё, что попадает в npm, публично, — поэтому вход построен на authorization code + PKCE (RFC 7636): клиент генерирует случайный code_verifier, отправляет на /authorize его SHA-256-хеш, а при обмене кода предъявляет оригинал. Механику я подробно разбирал в прошлой статье, здесь важны два вывода. Первый: обмен кода у Яндекса проходит без секрета, а grant_type=refresh_token — нет, поэтому встроенный публичный клиент рефрешем не пользуется вовсе, ему хватает access-токена, живущего месяцами. Второй вывод оказался неверным.

Loopback вместо копипасты

Первым пунктом «граблей Яндекса» в той статье стояло: «http://-редиректы запрещены… Яндекс такие redirect_uri отклоняет». Отсюда вырос out-of-band-вход: после согласия Яндекс показывает код на странице, пользователь копирует его глазами и вставляет в терминал.

Вывод был неправильный. Вернувшись к вопросу, я дописал http://127.0.0.1:53682/callback в Callback URI того же самого приложения — редирект принялся, обмен кода прошёл без секрета с первого раза. Никакого запрета на http у Яндекса нет: loopback публичному клиенту разрешён ровно так, как предписывает RFC 8252 для нативных приложений. Отказ формы при первой попытке я истолковал как ограничение платформы, хотя это было ограничение того, как я её заполнял. Неприятность такой ошибки в том, что она не остаётся в коде: неверный вывод переезжает в README, в статью и в чужие представления о платформе.

Теперь перед открытием браузера сервер поднимает одноразовый HTTP-сервер на фиксированном порту, ждёт ровно один запрос, сверяет state, забирает код и закрывается. Три момента, на которых легко испортить эту схему:

Порт фиксированный, и это не лень. redirect_uri при обмене кода должен совпадать с зарегистрированным символ в символ, поэтому «взять любой свободный порт» не сработает. По той же причине 127.0.0.1 и localhost — не синонимы.

state обязателен. Локальный порт открыт всему, что крутится на машине, и на него может прилететь чужой редирект. Сравнение state отсекает его до попытки обменять чужой код.

Фолбэк — только на ошибку бинда. Порт может быть занят, а по SSH браузер не откроется вовсе — тогда нужен старый путь с копированием кода. Но переключаться на него следует именно при неудачном listen, а не при любой ошибке:

function isBindError(err: unknown): boolean {    const code = (err as { code?: string } | null)?.code    return code === 'EADDRINUSE' || code === 'EACCES' || code === 'EADDRNOTAVAIL'}

Иначе реальные ошибки обмена — протухший код, неверный верификатор — молча превратятся в предложение «вставьте код руками», и отлаживать это будет нечем.

Логин как обычный инструмент MCP

Loopback убирает копипасту, но не убирает поход в терминал. Его убирает другое решение: сервер стартует без токена, а вход становится ещё одним инструментом, который модель вызывает сама.

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

export const NOT_AUTHENTICATED_MESSAGE =    'Not signed in. Use the `login` tool to sign in (it opens your browser), ' +    "or run the server's `auth` command in a terminal."

Вторая половина — инструменты login и submit_code. Их описания пишутся не для человека, а для модели, поэтому в них сказано, когда инструмент нужен, сколько раз его звать и что делать, если он вернул не токен, а ссылку:

description:    'Sign in to Yandex from here. Opens your browser to approve access; the code returns ' +    'automatically over a local redirect, so this usually finishes in one call. If the local port ' +    'is unavailable it returns a URL to approve and you then call submit_code with the code Yandex ' +    'shows. Run this once (the token lasts ~1 year); needed before the data tools if you are not ' +    'signed in yet.',

Внутри — тот же loopback-флоу, только вызов инструмента блокируется до возврата браузера (не дольше двух минут, чтобы неотвеченное согласие не подвесило клиент). Полученные токены одновременно пишутся на диск и подхватываются живым провайдером, иначе сервер остался бы «незалогиненным» до перезапуска.

В диалоге это выглядит так: пользователь просит показать его сайты, агент вызывает get_hosts, получает «не залогинен, используй login», вызывает login, у пользователя открывается браузер, он нажимает «Разрешить» — и агент в том же ответе продолжает с данными. Ни одного переключения в терминал; про существование токена пользователь вообще не узнаёт.

Грабля, о которой я обещал рассказать

Тот самый кэш user_id. Первая версия мемоизировала промис целиком — и при старте без токена первый же вызов инструмента ронял запрос к /v4/user, отклонённый промис оседал в кэше, а после успешного входа все восемь инструментов продолжали отдавать «не залогинен» до перезапуска сервера. Мемоизировать нужно только успех:

export function createUserIdResolver(client: WebmasterClient): () => Promise<number> {    let pending: Promise<number> | undefined    return () => {        if (!pending) {            pending = getUserId(client).catch((err: unknown) => {                pending = undefined                throw err            })        }        return pending    }}

Из той же серии — ретраи. HTTP-клиент повторяет запросы с экспоненциальным backoff, это правильно для 429 и 503. Но «пользователь не залогинен» приходит не от API, а от провайдера токена, и повторять тут нечего: четыре попытки с паузами дают семь с половиной секунд ожидания вместо мгновенного ответа. Ретраить нужно только то, что клиент сам породил как ответ API.

Где лежит токен и почему приложений два

Токен пишется в ~/.config/<имя-сервера>/token.json с правами 0600, каталог — 0700, причём chmod применяется, даже если файл уже существовал: созданный когда-то под другим umask, он иначе так и останется читаемым для всех. В конфиге MCP-клиента не хранится ничего, кроме команды запуска, — конфиг MCP это обычный JSON, который люди показывают в чатах, коммитят в дотфайлы и прикладывают к issue целиком, а токен Яндекса живёт месяцами.

Когда серверов стало два, напрашивалось одно OAuth-приложение на оба: один вход, один токен. Я от этого отказался — у них разные права:

Метрика

Вебмастер

scope

metrika:read

webmaster:hostinfo webmaster:verify

кэш токена

~/.config/yandex-metrica-mcp/

~/.config/yandex-webmaster-mcp/

OAuth-хост

oauth.yandex.com

oauth.yandex.ru

Общий токен означал бы, что человек, поставивший только сервер для Вебмастера, попутно выдаёт доступ к статистике всех своих счётчиков. Про третью строку: oauth.yandex.ru и oauth.yandex.com работают с одним бэкендом, но redirect_uri зарегистрирован на конкретном домене, и весь флоу надо вести на нём же — мелочь ровно до того момента, пока не потратишь на неё час.

Сама авторизация вынесена в общий пакет, потому что она одинакова по протоколу: PKCE, loopback, хранилище токенов, инструменты входа. А вот HTTP-клиент общим не стал, хотя выглядел очевидным кандидатом: у двух API одного вендора расходятся ровно те места, где клиент принимает решения — повторяющиеся ключи против CSV, POST с телом против чистого GET, разные конверты ошибок. Общий клиент на таких вводных получается с двумя характерами и флагом-переключателем внутри; он стареет быстрее, чем сотня строк дублирования. Правило, к которому я пришёл: выносить общее по протоколу, а не общее по вендору.

Что в итоге получается

Собрав всё вместе: вы добавляете сервер в MCP-клиент одной командой запуска, без переменных и токенов, задаёте первый вопрос — агент сам замечает, что не авторизован, открывает браузер, а после вашего «Разрешить» продолжает с данными. Дальше «покажи запросы с высокими показами и низкими кликами за месяц» превращается в вызов search_queries с report="top", а ответ возвращается компактной таблицей, а не портянкой JSON. Если рядом подключён сервер для Метрики, следующий шаг — «а что происходит на посадочных страницах этих запросов» — агент делает сам, без вашего участия в раскладке задачи на два источника.

Главные уроки, которые я вынес:

  • Группировать эндпоинты стоит по вопросам, а не по вызовам. Восемь инструментов вместо двух десятков — это не экономия кода, а снижение вероятности, что модель выберет не тот инструмент. По той же причине идентификатор, который нельзя угадать, требует явного инструмента обнаружения с пометкой «вызови меня первым».

  • Ошибка, адресованная модели, должна содержать следующий шаг. Разница между 401 и 403 для человека косметическая, для агента — разница между «вошёл и пошёл дальше» и бесконечным циклом входов.

  • Авторизация — часть UX инструмента, а не подготовка к нему. Сервер, который падает без токена, обрывает сценарий в самой хрупкой точке; сервер, который стартует и умеет объяснить модели, как войти, превращает установку в один диалог. Цена — мелочи вроде кэша, который не должен запоминать отказы.

  • Отказ интерфейса — не документация. Одно неверно истолкованное сообщение формы регистрации пережило README и целую статью, заставляя каждого пользователя копировать код руками. Такие ограничения проверяются экспериментом, и ровно один раз.

Сервер написан на TypeScript, распространяется под MIT и лежит в открытом репозитории — если хотите посмотреть разбор API целиком или взять код входа основой для своей интеграции с Яндекс ID. Официального MCP-сервера у Вебмастера нет, так что буду рад замечаниям и разбору чужого опыта в комментариях: как вы решаете первый вход в распространяемых инструментах — loopback, device-флоу, токен в конфиге? И как группируете инструменты, когда эндпоинтов у API заметно больше, чем разумно показывать модели?

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