Как научить LLM-агента спрашивать Яндекс Метрику: разбираем протокол MCP и OAuth-авторизацию без секрета

от автора

Веб-аналитика живёт за дашбордами и HTTP-API. Чтобы получить ответ на простой вопрос вроде «откуда пришёл трафик за прошлую неделю и сколько из этого дошло до цели», человек лезет в интерфейс Метрики, а программа — собирает запрос к Reporting API, помнит про namespace полей, лимиты и форматы ответа. LLM-агент (Claude, модель в Cursor, любой другой) по умолчанию не умеет ни того, ни другого: у него нет ни доступа к вашему счётчику, ни знания о том, как устроен API.

Мостом между агентом и внешней системой служит MCP — Model Context Protocol. В этой статье я разберу, как устроен MCP-сервер для Яндекс.Метрики: как маппить отчёты Метрики на небольшой набор инструментов, как авторизоваться в Yandex ID без клиентского секрета (это оказалось самой нетривиальной частью) и на какие грабли API я наступил по дороге. Код — на TypeScript; сам сервер open-source под MIT, ссылка в конце.

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

Что такое MCP и зачем он здесь

MCP — открытый протокол, описывающий, как приложение-хост (клиент с LLM) общается с внешними серверами, которые предоставляют модели инструменты (tools), ресурсы и промпты. Идея простая: вместо того чтобы зашивать интеграции в каждый чат-клиент, вы поднимаете отдельный процесс-сервер, а хост подключается к нему по стандартному транспорту (обычно stdio) и получает список инструментов с их JSON-схемами. Дальше модель сама решает, какой инструмент и с какими аргументами вызвать, а хост исполняет вызов и возвращает результат в контекст.

Для аналитики это удобная модель: сервер инкапсулирует всё неприятное — авторизацию, формирование запроса, разбор ответа, дисциплину по объёму данных, — а модель оперирует человекопонятными инструментами.

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

  • run_report — гибкая обёртка над Reporting API (/stat/v1/data): произвольные метрики, измерения, фильтры, сортировка;

  • run_comparison — сравнение двух периодов с абсолютной и процентной дельтой;

  • run_drilldown — проваливание по дереву измерения (ОС → версии ОС и т.п.);

  • run_timeseries — метрики, разложенные в динамику по времени (/bytime);

  • get_metadata — обнаружение доступных счётчиков, целей и типовых полей, чтобы модель строила запрос по реальным именам, а не угадывала их.

Дальше — самое интересное: как вообще пустить сервер к данным пользователя.

Авторизация: PKCE без клиентского секрета

Первое, что нужно решить в интеграции над Яндексом, — это OAuth. И здесь меня ждал главный сюрприз.

Проблема распространяемого клиента

Сервер ставится через npx и запускается на машине пользователя. Значит, встроить в него client_secret нельзя: всё, что попадает в npm-пакет, публично. Классический ответ для «публичных» клиентов (SPA, мобильные, CLI) — Authorization Code + PKCE (RFC 7636): клиент генерирует случайный code_verifier, отправляет на /authorize его хеш (code_challenge, метод S256), а при обмене кода на токен предъявляет исходный verifier. Секрет не нужен — подлинность запроса подтверждается тем, что только этот клиент знает verifier.

Генерация пары — несколько строк на стандартной node:crypto:

import { createHash, randomBytes } from 'node:crypto'function base64Url(buf: Buffer): string {    return buf.toString('base64')        .replace(/\+/g, '-')        .replace(/\//g, '_')        .replace(/=+$/, '')}export function generatePkce() {    const verifier = base64Url(randomBytes(48))    const challenge = base64Url(createHash('sha256').update(verifier).digest())    return { verifier, challenge, method: 'S256' as const }}

Три грабли Яндекса, которых нет в учебнике по OAuth

Дальше начались расхождения между «как в спеке» и «как у Яндекса». Я проверял всё живьём против oauth.yandex.com, и вот что выяснилось.

1. http://-редиректы запрещены. Стандартный для CLI приём — поднять локальный сервер на http://127.0.0.1:<port> и поймать редирект с кодом. Яндекс такие redirect_uri отклоняет. Поэтому пришлось использовать out-of-band редирект: https://oauth.yandex.com/verification_code. После подтверждения согласия Яндекс показывает код на странице, пользователь копирует его в терминал.

2. Refresh-токен требует секрет. Здесь и кроется главная асимметрия флоу. Обмен authorization code на токен по PKCE проходит без секрета — и Яндекс выдаёт при этом токен, живущий около года. А вот grant_type=refresh_token Яндекс без client_secret не принимает. Вывод получился неожиданно удобным: раз токен и так живёт год, встроенному публичному клиенту refresh не нужен вовсе — по истечении пользователь просто логинится заново. Refresh включается, только если пользователь подставит собственное OAuth-приложение с секретом. То есть «отсутствие секрета» — не костыль, а осознанный режим по умолчанию.

3. Заголовок авторизации — не Bearer. Об этом ниже, в разделе про API, но упомяну сразу, потому что на этом легко потерять час.

Обмен кода на токен в итоге выглядит так (секрет отправляется, только если он вообще есть):

const body = new URLSearchParams({    grant_type: 'authorization_code',    code,    client_id: clientId,    code_verifier: verifier,    redirect_uri: 'https://oauth.yandex.com/verification_code',})if (clientSecret) body.set('client_secret', clientSecret)const res = await fetch('https://oauth.yandex.com/token', {    method: 'POST',    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },    body,})

Полученный токен кэшируется в файле с правами 0600 — читать может только владелец. Логин делается один раз командой, отдельного «приложения» регистрировать не надо: публичный client_id уже вшит в пакет, а это, как мы выяснили, безопасно.

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

Reporting API Метрики мощный, но у него есть особенности, которые всплывают только на практике. Собрал те, что оказались «несущими».

Хост и заголовок

Базовый хост — https://api-metrika.yandex.net (именно с дефисом, не api.metrika). А заголовок авторизации — буквально:

Authorization: OAuth <token>

Не Bearer. Если по привычке поставить Bearer, получите 403, и не сразу поймёте почему.

Один namespace на запрос

Поля Метрики живут в пространствах имён: визиты — ym:s:*, просмотры — ym:pv:* и т.д. В одном запросе нельзя смешивать namespace: либо всё ym:s:, либо всё ym:pv:. Смешанный запрос отклоняется. Это стоит валидировать на своей стороне до отправки, чтобы вернуть модели понятную ошибку, а не сырой 400.

Метрики в ответе — позиционные и бывают null

В строке ответа нет имён метрик: массив metrics сопоставляется с запрошенными по позиции. Запросили ["ym:s:visits","ym:s:users"] — получите [1234, 890] в том же порядке. Причём значение может быть null (например, при делении на ноль в производных метриках). Оба факта надо закладывать в разбор, иначе схема поедет на первом же граничном случае.

Нет API для перечисления полей

Неприятный сюрприз: эндпоинта, который вернёт список доступных измерений и метрик, не существует. Каталог полей есть только в документации. Для агента это проблема — модель начнёт выдумывать имена вроде ym:s:pageviews, которых нет. Решение — забить курируемое подмножество полей прямо в сервер и отдавать его инструментом get_metadata вместе с живым списком счётчиков и целей из Management API. Тогда модель строит запрос по реальным именам.

Троттлинг: 420 или 429?

Отдельная история — лимиты. На пользователя: 200 запросов за 5 минут к отчётам, 3 параллельных, 5000 в день. И здесь документация сама себе противоречит: страница про квоты называет статус троттлинга 420, а страница ошибок — 429. Заголовка Retry-After в доках нет. Практический вывод: считать троттлингом и 420, и 429, дополнительно проверять error_type с префиксом quota_, и делать собственный экспоненциальный backoff, ни на какой Retry-After не рассчитывая.

Полный список расхождений, которые я задокументировал по ходу, приведу под спойлером — если пишете свой клиент, сэкономит время.

Ещё грабли, которые лучше не хардкодить вслепую
  • /bytime не отдаёт поле time_intervals — ось времени приходится реконструировать из date1/date2/group.

  • Форматы ответа разные у разных эндпоинтов: у /drilldown измерение в строке — единственное (dimension, не массив dimensions); у /comparison метрики приходят как { a: [...], b: [...] }.

  • Нет документированного оператора фильтра для «null / не задано» — не изобретайте свой.

  • Часть «булевых» полей приходит числами 0/1 (наблюдал живьём у is_favorite цели и favorite счётчика) — парсить гибко и нормализовать в boolean.

  • Схема conditions[] у цели зависит от типа цели — моделировать свободно.

  • Верхнеуровневая мета ответа — везде snake_case (total_rows, sample_share, data_lag, …).

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

Отдельная тема, специфичная именно для LLM-интеграций, а не для обычного клиента. Контекст модели — дорогой и конечный ресурс. Если инструмент вываливает в него сырой ответ API на сто строк по двадцать метрик, вы одновременно и жжёте токены, и топите полезный сигнал в шуме. Многие существующие интеграции этим грешат.

Поэтому сервер по умолчанию:

  • включает выбор полей — модель получает то, что спросила, а не весь ответ;

  • держит низкий лимит строк по умолчанию (сотня), а не максимум;

  • явно возвращает модели факт семплирования и остаток квоты — чтобы агент понимал, что данные приблизительные, и мог об этом сказать.

Плюс сервер по умолчанию read-only: инструменты только читают отчёты, ничего не меняют в счётчике. Для аналитического ассистента это правильный дефолт и по безопасности, и по доверию.

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

Собрав всё вместе, вы подключаете сервер к MCP-клиенту (в конфиге — команда запуска и id счётчика), один раз логинитесь, и дальше агент отвечает на вопросы на естественном языке, сам выбирая инструмент и поля. «Сколько визитов и пользователей было на прошлой неделе в разбивке по источникам» превращается в вызов run_report с metrics: ["ym:s:visits","ym:s:users"] и dimensions: ["ym:s:lastsignTrafficSource"], а ответ возвращается компактной таблицей, а не портянкой JSON.

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

  • PKCE-флоу Яндекса нестандартен: нет http-редиректов (только out-of-band), refresh требует секрет, зато access-токен живёт ~год — этого достаточно, чтобы распространяемый клиент вообще обходился без секрета.

  • API Метрики надо изучать по поведению, а не только по гайду: OAuth вместо Bearer, один namespace на запрос, позиционные и nullable-метрики, противоречие 420/429, отсутствие API-перечисления полей.

  • Для LLM-интеграции дисциплина контекста — не опция, а часть дизайна: выбор полей, лимиты и честный сигнал о семплировании важны не меньше самой бизнес-логики.

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

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