Данные о том, как поиск видит ваш сайт, живут в двух местах: в интерфейсе Яндекс Вебмастера и в его 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 |
|
|
кэш токена |
|
|
OAuth-хост |
|
|
Общий токен означал бы, что человек, поставивший только сервер для Вебмастера, попутно выдаёт доступ к статистике всех своих счётчиков. Про третью строку: 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 заметно больше, чем разумно показывать модели?
