Skip to main content
Glama
conarti

yandex-messenger-mcp

by conarti
README.md
# yandex-messenger-mcp

MCP-сервер для Яндекс Мессенджера. Даёт агенту читать переписку, искать по ней, скачивать вложения и отправлять текст от вашего имени.

Работает на той же сессии, что и веб-клиент: Playwright один раз логинится в Яндекс и держит persist-профиль, дальше протокол (WebSocket + HTTP) гоняется в Node на извлечённых cookie. Публичного API у Мессенджера нет, поэтому протокол снят реверсом веб-клиента `chats-web` - отсюда честный раздел [Ограничения](#ограничения-и-пробелы-в-доказательствах) внизу, его стоит прочитать до того, как полагаться на инструмент.

## Инструменты

| Инструмент | Что делает |
|---|---|
| `list_chats` | Список чатов: последнее сообщение, счётчик непрочитанных, свежие первыми |
| `get_history` | Страница сообщений чата с пагинацией по курсору |
| `get_message` | Одно сообщение по `chat`+`message_id` либо по join-ссылке, без загрузки истории |
| `get_message_context` | Окно сообщений вокруг метки: N сообщений до и N после |
| `search` | Поиск по сообщениям, людям и чатам |
| `send_message` | Отправка текста, с упоминаниями/ответом/пересылкой. Двухшаговая: draft, затем confirm |
| `send_file` | Отправка картинки или файла. Двухшаговая: draft (байты не льются), затем confirm (необратимо) |
| `set_reaction` | Поставить/снять реакцию. Одним вызовом, без confirm (реверсибельно) |
| `list_reactions` | Полный список поставивших реакцию и прочитавших (кто и когда), без обрезки |
| `mark_read` | Отметить чат прочитанным. Одним вызовом, без confirm |
| `pin_message` | Закрепить/открепить сообщение. Одним вызовом, без confirm |
| `delete_message` | Удаление своего сообщения. Двухшаговая: draft, затем confirm (необратимо) |
| `edit_message` | Правка своего сообщения. Двухшаговая: draft, затем confirm (необратимо) |
| `get_poll` | Чтение опроса: варианты, мой выбор, результаты. Одним вызовом, без confirm |
| `vote_in_poll` | Голос в опросе. Двухшаговая: draft, затем confirm. Форма подтверждена живьём |
| `get_thread` | Сообщения треда как микро-чата (по `thread_id` либо родительскому сообщению) |
| `join_to_thread` / `leave_thread` | Подписка на тред и выход. Одним вызовом, без confirm |
| `download_attachment` | Скачивает вложение по рефу в папку загрузок |

### `list_chats`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `limit` | 1-500 | 50 |
| `unread_only` | bool | false |
| `include_last_message_text` | bool | false |

Отдаёт `{chats, total_chats, unread_chats}`. У чата: `chat_id`, `name`, `kind` (`private`/`group`), `last_activity`, `unread_count`, `unread`, `muted`, `last_message`.

`include_last_message_text:true` возвращает полный текст последнего сообщения каждого чата. По умолчанию отдаются только метаданные последнего сообщения (без текста, цитат и имён файлов) - чтобы содержимое чужих переписок не попадало в контекст модели без явного запроса.

### `get_history`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `chat` | ChatId либо поисковый запрос | обязателен |
| `limit` | 1-200 | 40 |
| `before` | курсор: мкс строкой | нет |
| `from_date` | ISO-дата/время, нижняя граница включающая | нет |
| `to_date` | ISO-дата/время, верхняя граница исключающая | нет |
| `after` | ISO-дата/время, строго после метки (альтернатива `from_date`) | нет |

Если `chat` задан запросом и совпадений несколько, инструмент возвращает `status:"ambiguous_chat"` со списком кандидатов и **не гадает**. Пагинация: взять `next_before` из выдачи и передать его в `before` следующего вызова.

Фильтр по времени - альтернатива курсору. `from_date` включает сообщения от этой даты и позже, `to_date` исключает сообщение ровно на границе (верхняя граница исключающая), поэтому «сообщения за сегодня» берутся одним вызовом: `from_date` = начало дня, `to_date` = начало следующего. `after` - то же, что `from_date`, но строго после метки. При совпадении обеих форм одной границы курсор `before` приоритетнее `to_date`, а `after` приоритетнее `from_date`.

Self-чат «Избранное» **резолвится по имени**, а не только литеральным ChatId: резолвер узнаёт его по гейту `<myGuid>_<myGuid>` (пара одинаковых guid, `PrivateChatInfo` + `PartnerInfo.Guid == myGuid`) и больше не выбрасывает вас самих как собеседника. Резолв не-self чатов при этом не меняется.

Вложения приходят **только рефами** (`file_id`, `name`, `size`, `kind`). Ничего не качается - это отдельное явное действие через `download_attachment`.

Каждое сообщение помимо v1-полей несёт обогащение (аддитивно, старые ключи не меняются):

| Ключ | Что несёт |
|---|---|
| `from_me` | автор == я; `null`, если `From` срезан фильтром (автор неопределим, это **не** «не моё») |
| `reads` | прочтения: `{tracked, count?, recent?, seen_by_partner_mcs?}`. Отсутствие read-state = `{tracked:false}`, **не** ноль |
| `mentions` | упоминания в имена; нет имени для guid - явный `{guid, unresolved:true}`, а не guid вместо имени |
| `reactions` | реакции сгруппированы по типу: `Reaction {type, name, emoji, count?, actors[], actors_complete}`; неизвестный тип - `{type, name:null, emoji:null, unknown:true}`. `actors[]` - кто поставил (`{guid?, name?, timestamp?, timestamp_mcs?}`), здесь усечённый сиблинг `history`; `actors_complete` - сравнение `count` с длиной `actors[]`, `false` значит обрезку. Полный список без обрезки - инструмент `list_reactions` |
| `thread` | признак `has_thread` + корень треда `root` |
| `forwarded` | оригиналы пересылки: `{source_author, source_chat, source_date, source_date_mcs, source_text, attachments}` - **отдельным ключом**, не внутри `context`. Блок из нескольких пересылок приходит несколькими элементами |

**Реакции: `type` - id артворка, `emoji` - аппроксимация.** Яндекс рендерит реакции PNG-артворком по id (`/reactions/{type}/{size}`), а не Unicode-эмодзи. Карта `type -> {name, emoji}` лежит данными в [`src/config/reaction-map.json`](src/config/reaction-map.json) (52 записи, перегенерируется свипом по `/reactions/{type}/small`). Авторитетны `type` (пришёл с провода) и `name` (имя ассета с сервера); колонка `emoji` - **наша аппроксимация артворка**, её нельзя выдавать за «эмодзи от Яндекса». Пространство типов открыто (сервер принимает любой int), поэтому карта - lookup-с-фолбэком, а не полный справочник: неизвестный тип отдаётся как `unknown`, `chr(type)` в emoji не подставляется. Акторы и `actors_complete` в этом обогащении - усечённый сиблинг `history`; полный список поставивших/прочитавших (кто и когда), без обрезки, - инструмент [`list_reactions`](#list_reactions) (двумя WS-вызовами: `Mode` - дискриминатор, дефолт -> `UserReactions`, `Mode:1` -> `UserReads`+`ReadsCount`).

**Форматирование - сырая строка.** Протокол **не даёт структурированных entities** (ranges/spans): `Text` несёт ровно `MessageText`, markdown-символы едут как есть, разметку рисует клиент (проверено живьём, §17.14). `text` отдаётся без разбора; структуру форматирования тут не выдумываем.

**Удалённое сообщение: `kind:'deleted'` (изменение формы выдачи).** Раньше удалённое приходило с `kind:'unknown'` - тем же значением, что и сообщение с нераспознанным телом, и различить их было нечем. Теперь у удалённого собственное значение, а `'unknown'` означает строго «тела нет либо content-поля нет». **Путь миграции:** если ваш код искал удалённые по `kind === 'unknown'`, переходите на поле `deleted` - оно было и есть, ничего не меняло и остаётся надёжнее любого `kind`.

**Пересылка распознаётся не по `kind`, а по данным.** `kind` пересланного сообщения - это вид его СОДЕРЖИМОГО: пересланная картинка остаётся `'image'`, а чистая пересылка без своего комментария приходит как `'unknown'` (её тело действительно пустое, содержимое лежит в оригиналах). Признак пересылки - непустой `forwarded[]`; там же текст и вложения оригиналов. **Канал полный:** `forwarded[]` даётся всеми инструментами, которые отдают сообщения, включая [`search`](#search) - он обогащается тем же слоем. `mark_read` сообщений не отдаёт вовсе, обогащение внутри него нужно лишь чтобы взять метку самого свежего. Дочитывать оригиналы инструмент сам не ходит: адрес каждого оригинала (`source_chat` + `source_date_mcs`) отдаётся в `forwarded[]`, дальше по нему при желании вызывается `get_message`.

### `get_message`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `chat` | ChatId либо поисковый запрос; нужен вместе с `message_id` | - |
| `message_id` | timestamp сообщения в микросекундах (строка); нужен вместе с `chat` | - |
| `url` | join-ссылка Мессенджера (альтернатива паре `chat`+`message_id`) | - |
| `with_reactions` | bool: тянуть детальные реакции/прочтения (2 доп. WS-вызова) | true |

Одно сообщение без загрузки истории (`message_info`, один WS-вызов). Адресация - либо парой `chat`+`message_id` (то же резолвение чата, что у `get_history`), либо готовой join-ссылкой (`url`): из неё резолвится и чат, и метка сообщения. Трёхсегментная ссылка (сообщение внутри треда) адресуется той же деривацией `thread_id`, что и `get_thread`; для бизнес-чата (`2/...`), где деривация недоступна, возвращается `status:"thread_unsupported"` с причиной, а не тихий отказ.

`with_reactions` по умолчанию `true`: для ОДНОГО сообщения два дополнительных вызова `list_reactions` дёшевы, поэтому `reactions_detail` (полные реакции и прочтения, без обрезки) приезжает вместе с сообщением. `with_reactions:false` возвращает только само сообщение с обычным обогащением (усечённые сиблинги `reactions`/`reads`, как в `get_history`).

Ответ также несёт `my_reactions` (мои реакции на сообщение; ключ пропадает, если своих реакций нет) и сырой `chat_info` (метаданные чата с провода, отдаются как есть).

### `get_message_context`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `chat` | ChatId либо поисковый запрос | обязателен |
| `message_id` | timestamp целевого сообщения в микросекундах (строка) | обязателен |
| `before` | сколько сообщений ДО метки, 0-200 | 10 |
| `after` | сколько сообщений ПОСЛЕ метки, 0-200 | 10 |

Окно вокруг конкретного сообщения - когда важен не курсор, а то, что было сказано непосредственно до и после метки. Отдельного «context»-метода на проводе нет: окно строится теми же границами `history`, что и `get_history`. Выдача несёт три ключа: `before` (от старых к новым), `message` (само сообщение по метке, если оно ещё живо - не удалено и не отфильтровано), `after` (от старых к новым).

### `search`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `query` | строка | обязателен |
| `entities` | `messages`, `users`, `chats` | все три |
| `limit` | стартовый limit | 50 |

Серверной пагинации у поиска нет (см. [Ограничения](#ограничения-и-пробелы-в-доказательствах)), поэтому полнота достигается эскалацией `limit`: пока выдача насыщена, `limit` поднимается и запрос повторяется. Как это отработало, видно в ответе: `escalation: {start_limit, final_limit, requests}`. Если упёрлись в клиентский потолок, придёт `truncated:true` с причиной, а не молча обрезанный список.

Entity `contacts` невалиден и отвергается на входе.

### `send_message`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `text` | текст сообщения |
| `mentions` | упоминания участников (опционально) |
| `reply_to_message_id` | timestamp сообщения-цели ответа, мкс строкой (опционально) |
| `forward_from` | timestamp пересылаемого сообщения, мкс строкой (опционально) |
| `confirm` | `true` - отправить; иначе draft |
| `confirm_token` | токен из draft, обязателен при `confirm:true` |

Подробности двухшаговой отправки ниже: [Двухшаговая отправка](#двухшаговая-отправка).

**Упоминания (`mentions`).** Двухшаговый контракт: на **draft** элементы `mentions` - это запросы (`@Имя`, `@<guid>` либо голый guid), каждый резолвится в guid участника; неоднозначность (несколько кандидатов) или отсутствие результата отклоняет весь вызов целиком, не только спорное упоминание. Ответ несёт `mentions: [{guid, name?}]` - уже резолвнутые. На **confirm** нужно предъявить РОВНО те же guid, в том же порядке, что вернул draft: изменение состава или порядка отклоняет отправку (порядок значим - он часть отпечатка нагрузки). Резолв идёт по **каталогу организации** (глобальный поиск людей), а НЕ по участникам конкретного чата: `@Имя` может найти не того тёзку. Для приватного чата есть бесплатная проверка принадлежности - оба guid собеседников лежат в самом `chat_id` (`<guidA>_<guidB>`), резолвнутый guid вне этой пары отклоняется (`mention_not_in_chat`). Для группового чата такой проверки нет (эндпоинта участников группы в протоколе не нашлось) - точный адрес гарантирует только явный `@<guid>`, а не поиск по имени.

**Подстановка токена и поле `text_preview`.** Упоминание рендерится клиентом только тогда, когда в самом `MessageText` стоит токен `@<guid>`; одного `MentionedUserIds` недостаточно (#15). Поэтому на **draft** каждая названная в `mentions` строка заменяется в тексте на канонический токен, и `draft.text` возвращается уже подставленным - именно он предмет отпечатка и именно его надо вернуть эхом на confirm. Подстановка идёт **по строкам, которые назвал вызывающий**, а не по позиции: порядок массива `mentions` с порядком вхождений в тексте не связан, и позиционное сопоставление подставило бы guid не того человека. Плейсхолдер, присутствующий в тексте, но не названный в `mentions`, не трогается; `petr@Имя` тоже не трогается (левая граница). Рядом отдаётся `text_preview` - читаемая проекция того же текста, где guid развёрнуты обратно в имена: **только для чтения**, в отпечаток не входит, эхо превью вместо `text` отправку отклонит. Оно существует потому, что по строке с 37-символьными guid человек не видит, кто упомянут в каком месте. Форма токена на исходящем **подтверждена живьём** (2026-08-28, self-чат: отправленное перечитано с провода, в сыром тексте ровно один `@<guid>`, совпадающий с обогащённым `mentions[]`); а вот **факт пуш-уведомления адресату живьём не наблюдался** - для этого нужен второй аккаунт.

**Правка и упоминания.** `edit_message` принимает необязательный `mentions` с той же семантикой запросов, что `send_message`. Без поля правка сохраняет упоминания цели, как раньше. С полем это заявленный ПОЛНЫЙ состав: запросы резолвятся, токены `@<guid>` подставляются в `new_text` до отпечатка, и на confirm возвращается эхом `will_text`, а не исходная строка. Рядом отдаётся `will_text_preview` с именами вместо guid - только для чтения, в отпечаток не входит. Пустой массив и отсутствие поля - РАЗНЫЕ вещи: первый стирает упоминания, второе их сохраняет, и отпечаток эти случаи различает, иначе draft одного режима подтверждался бы другим.

**Ответ (`reply_to_message_id`).** Timestamp цели входит в отпечаток нагрузки - его изменение между draft и confirm отклонит отправку. Цитата цели (`reply_quote` в draft) перечитывается с сервера отдельным запросом и в отпечаток НЕ входит: правка текста цели между draft и confirm отправку не отклоняет, потому что цитата на confirm берётся заново с сервера, а не из draft. Цитата обрезается до 200 символов (`quote_truncated:true`, если обрезана) - число выведено из реверса, живого случая цитаты длиннее 200 символов не было.

**Пересылка (`forward_from`).** Timestamp пересылаемого сообщения, тоже часть отпечатка. В отличие от ответа, пересылка идёт без цитаты.

### `send_file`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `path` | абсолютный путь к файлу или картинке на диске |
| `confirm` | `true` - залить и отправить; иначе draft (байты не льются) |
| `confirm_token` | токен из draft, обязателен при `confirm:true` |

Отправка картинки (`image`) или произвольного файла (`file`); тип определяется по расширению. **Voice и gallery не отправляются** - для них закрыт только долг чтения (см. [Non-Goals]). Двухшаговая: **draft показывает имя/размер/тип/чат и НЕ льёт байты** - заливка и все 3 шага (§12.1: `upload_to_disk` → PUT сырых байт → `add_files`) идут только на `confirm`. После `confirm` уходит обычное сообщение с `file_info.id`, и его можно прочитать обратно по `file_id` и скачать через `download_attachment`. Ошибки загрузки разделены: **507/403 → квота, 413 → размер**, не «upload failed». Ответ без числового `Status` = отказ, как и у `send_message`.

**Перезаливка при повторном confirm после рестарта.** Локальная память токена живёт в процессе. Если процесс рестартовал между draft и confirm, повторный confirm пройдёт 3 шага загрузки заново - байты уйдут повторно. **Дубля сообщения при этом нет:** `PayloadId` зафиксирован в токене на draft и переживает рестарт, поэтому повтор придёт серверу тем же id и вернётся `DUPLICATE` (нового сообщения не создаст). Потери - только повторно израсходованные байты, и они ограничены квота-ошибкой. Не блокер, но знать полезно.

### `set_reaction`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | timestamp сообщения в микросекундах (строка) |
| `type` | **целочисленный** id реакции (артворк, не emoji) из поля `reactions` сообщения |
| `remove` | `true` - снять реакцию (`Action:REMOVE`); иначе поставить |

Реверсибельно, поэтому **без confirm**: снятие откатывает постановку тем же вызовом. **Подтверждено живьём** (US-009, self-чат): постановка (`Reaction` без `Action`) -> реакция видна через [`list_reactions`](#list_reactions); снятие (`Action:REMOVE`) -> реакция исчезает; `Status:1` в обоих случаях. `type` **валидируется по карте `reaction-map.json` ДО отправки** - неизвестный тип отвергается на входе со `status: invalid_type` и на провод не уходит (сервер `Type` не валидирует и принял бы любой int, включая мусор). Реакция уходит **полным конвертом `ClientMessage`** (плоский `push({Reaction})` дал бы ложный `NO_SUCH_CHAT`).

### `list_reactions`

| Параметр | Тип | По умолчанию |
|---|---|---|
| `chat` | ChatId либо поисковый запрос для резолва чата | обязателен |
| `message_id` | timestamp целевого сообщения в микросекундах (строка) | обязателен |
| `limit` | лимит на провод в обоих вызовах | 50 |
| `invite_hash` | для чтения по join-ссылке | нет |

Без confirm (чтение). Полный список «кто и когда» по сообщению - реакции (сгруппированы по типу, `actors_complete:true` ВСЕГДА, в отличие от `get_history`/`get_message`/`get_message_context`/`get_thread`, где акторы реакций - усечённый сиблинг агрегата) и прочтения. Стоит ДВА WS-вызова (`UserReactions` + `UserReads`/`Mode:1`) - дороже обогащения на сиблингах, зато без обрезки: сиблинги `history` обрезаны и источником истины не служат (живьём видено `ReadsCount:10` при `RecentUserReads` длиной 3). `limit` обязателен на проводе в обоих вызовах - без него сервер отвечает `BACKEND_CALL_ERROR(2)`.

### `mark_read`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | опционально: timestamp (мкс), до которого отметить прочитанным; без него - до самого свежего сообщения |
| `seqno` | опционально: SeqNo той же границы |

Без confirm (безобидно). Без `message_id` тянет последнюю страницу истории и отмечает прочитанным до самого свежего сообщения. **Форма и эффект подтверждены живьём** (US-009): маркер `SeenMarker` бэкенд принимает, и именно он пишет seen-позицию, обнуляя непрочитанное (перебором: в уже прочитанном self-чате `SeenMarker` отвечает `DUPLICATE`, а `ReadMarker`/`UnseenMarker` коммитят заново; на чате с реальным непрочитанным от другого аккаунта `unread_count` ушёл с 2 до 0). В выдаче `form_status: verified` (см. [Ограничения](#что-не-подтверждено-живьём)).

### `pin_message`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | опционально: timestamp (мкс) закрепляемого сообщения; без него - открепить |

Без confirm (легко откатить). **Семантика подтверждена живьём** (US-009): закреп с меткой добавляет `PinnedMessageInfo` на это сообщение, а `Pin` без метки его убирает (открепление) - проверено на проводе через `ChatData`. В выдаче `form_status: verified`.

### `delete_message`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | timestamp (мкс) удаляемого сообщения |
| `confirm` | `true` - удалить; иначе draft |
| `confirm_token` | токен из draft, обязателен при `confirm:true` |

Двухшаговая (удаление необратимо). **Шаг 1** (без `confirm`): резолвит чат, **перечитывает** удаляемое (автор/время/текст) и возвращает превью с `confirm_token`. В сокет не уходит ничего - превью строится чтением. **Шаг 2** (`confirm:true` + токен): удаляет пустым `Plain{ChatId, Timestamp}` (§9.3). На confirm `chat` и `message_id` сверяются с подтверждёнными; расхождение - отказ. **Форма подтверждена живьём** (US-009, self-чат): `Status:1`, повторное чтение даёт `deleted:true` и пустой текст. Серверного дедупа на повторе **нет**: сырой replay того же удаления дважды на уже удалённом вернул снова `FULLY_COMMITTED` (переприменяет), от повтора защищает локальная память confirm-слоя. Удаление **чужого** сообщения отклоняет сервер (внятный commit-статус вроде `NO_PERMISSION`) - своего ограничения тут нет.

### `edit_message`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | timestamp (мкс) правимого сообщения |
| `new_text` | новый текст |
| `mentions` | новый ПОЛНЫЙ состав упоминаний (опционально) |
| `confirm` | `true` - применить правку; иначе draft |
| `confirm_token` | токен из draft, обязателен при `confirm:true` |

Двухшаговая (правка необратима). **Шаг 1**: резолвит чат, **перечитывает** сообщение и возвращает превью `was_text -> will_text` с `confirm_token`, ничего не меняя. **Шаг 2**: правит через `convertMessageToPlain` + `Timestamp` (§9.3). На confirm сверяются `chat`, `message_id`, `new_text` и состав `mentions` (смена любого инвалидирует токен). **Упоминания:** без поля `mentions` правка сохраняет упоминания цели, как раньше; с полем это заявленный ПОЛНЫЙ состав - запросы резолвятся, токены `@<guid>` подставляются в текст, и эхом на confirm возвращается `will_text`, а не исходная строка. Пустой массив и отсутствие поля - РАЗНЫЕ вещи: первый стирает упоминания, второе их сохраняет, и отпечаток эти случаи различает. Рядом отдаётся `will_text_preview` с именами вместо guid: только для чтения, в отпечаток не входит. Подробности - [Упоминания](#send_message). **Форма подтверждена живьём** (US-009, self-чат): `Status:1`, после правки сообщение читается с новым текстом, `edited:true` и непустым `edited_at` (`LastEditTimestamp`). Повторный `confirm` тем же токеном отдаёт запомненный результат, второго `push` не шлёт. Правку **чужого** отклоняет сервер.

### `get_poll`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | timestamp (мкс) сообщения-опроса |

Без confirm (чтение). `message_info` даёт вопрос/варианты/лимит выбора, `poll_info` (§14.3) - агрегат: `{is_poll, answers, my_choices, is_anonymous, voted_count, recent_voters, results}` плюс сырой ответ. `answers[i]` несёт `votes` и, для не-анонимного опроса с голосами, `voters` (имя+время из `AnswerVotes`); у анонимного опроса сервер скрывает список голосующих даже по явному запросу - это отражено флагом `voters_hidden:true` (виден только агрегат и свой выбор). Признак «это опрос» виден и здесь (`is_poll`), и в обычной выдаче сообщения (`kind:'poll'`). Не опрос - статус `not_a_poll`. Чтение разрешено против **любого** реального опроса в любом чате.

### `vote_in_poll`

| Параметр | Тип |
|---|---|
| `chat` | ChatId либо поисковый запрос |
| `message_id` | timestamp (мкс) сообщения-опроса |
| `choices` | массив выбранных вариантов (индексы/id) |
| `confirm` | `true` - проголосовать; иначе draft |
| `confirm_token` | токен из draft, обязателен при `confirm:true` |

Двухшаговая. Форма `Vote{ChatId, Timestamp, Action:0, Choices}` (§9.3/§11.4) **подтверждена живьём** (2026-07-17, `commit_status:1 FULLY_COMMITTED`): `Action:0` обязателен (без него - `BACKEND_CALL_ERROR(2)`), `Results` не шлётся, `Choices` - 0-based индексы в `Poll.Answers[]` и **полный** набор выбора. Голос **публичен и меняемый**: повторная отправка **заменяет** прежний выбор целиком (несколько вариантов - все индексы в одном `Choices`) - это подтверждено повторным чтением, где `my_choices` сменился с `[0]` на `[1]` после повторной отправки. `form_status` в выдаче - `verified` (AC-29 закрыт). **Почему confirm сохранён, хотя голос меняемый:** сам факт голоса необратим - `voted_count` растёт, а в не-анонимном опросе голосующий попадает в список голосовавших; отменить голос **до нуля** протоколом не подтверждено (единственный оставшийся мелкий вопрос).

### `download_attachment`

| Параметр | Тип |
|---|---|
| `file_id` | из `file_info` рефа сообщения |
| `chat_id` | опционально, только контекст вызывающего: в запрос не идёт |
| `size` | `SMALL`, `SMALL48`, `MIDDLE2048`, `ORIGINAL` - превью **для картинок**; без него скачивается оригинал |

Возвращает `{path, bytes, content_type}`. Повторный вызов **не идемпотентен**: имена вложений не уникальны (`photo.jpg` у всех), затирать чужой файл нельзя, поэтому повтор кладёт рядом копию с суффиксом.

## Треды

Тред - это чат: у него собственный `ChatId`, и всё, что умеет обычный чат (чтение, отправка, реакции, прочтения), работает в треде тем же способом - **паритет с обычным чатом**, а не отдельная механика.

- **Чтение:** `get_thread` открывает тред по готовому `thread_id` либо по паре `chat` + `message_id` родительского сообщения. Пагинация та же, что у `get_history`: `limit` (1-200, по умолчанию 40) и курсор `before` (мкс строкой - вернуть сообщения строго старше него; значение для следующей страницы - `next_before` из выдачи). Пустой (ещё не материализованный) тред приходит с `empty:true`, а не ошибкой доступа.
- **Создание = деривация, без сети.** Отдельного серверного «создать тред» **нет**. `thread_id` **выводится строкой** из родительского сообщения (§17.10, radix 10), и тред материализуется первым отправленным в него сообщением. «Обсудить» из веб-клиента - ровно эта деривация, реверса не требует. Бизнес-чаты (`2/...`) недоступны для деривации - это зафиксировано, а не забыто.
- **Отправка в тред** идёт обычным `send_message`, где в поле `chat` передан `thread_id` (тред = валидный ChatId) - с той же двухшаговой отправкой draft->confirm.
- **Подписка:** `join_to_thread` / `leave_thread` по `thread_id` - вступление и выход, обратимы, поэтому без confirm.

## Требования

- Node.js >= 22
- Chromium для Playwright

## Установка

### Через npx (рекомендуется)

Отдельно устанавливать пакет не нужно - `npx` скачивает его из реестра по требованию и
запускает. Разово нужен только браузерный движок, которым сервер управляет для входа:

```bash
npx playwright install chromium
```

Дальше сервер запускает MCP-клиент командой `npx yandex-messenger-mcp` - см.
[Подключение к MCP-клиенту](#подключение-к-mcp-клиенту). Руками её запускать не требуется.

### Из исходников (для разработки)

```bash
git clone https://github.com/conarti/yandex-messenger-mcp.git
cd yandex-messenger-mcp
npm install
npx playwright install chromium
npm run build
```

Скрипты сборки, тестов и typecheck - в разделе [Разработка](#разработка).

## Первый запуск и авторизация

Отдельного шага логина нет: авторизация ленивая и случается на первом вызове инструмента, который реально идёт к серверу. `tools/list` отвечает и без сессии.

Как это выглядит:

1. **Первый вызов.** Поднимается **headed**-браузер Playwright на странице Мессенджера. Войдите обычным способом: QR-код, пароль, что настроено у вас. Сервер ждёт появления сессии до 5 минут.
2. **Сессия сохраняется** в persist-профиле (`~/.config/yandex-messenger-mcp/profile/`). Дальше браузер не нужен: cookie извлекается, протокол гоняется в Node.
3. **Последующие запуски** работают на сохранённом профиле, ручной вход не требуется.
4. **Протухание.** Когда Яндекс отвергает cookie, профиль поднимается **headless** и даёт Паспорту рефрешнуть сессию (обычно пара секунд, незаметно). Если рефреш не помог, происходит эскалация в headed-логин, и вас снова попросят войти руками.

Единственный канал хрупкости здесь - сама cookie-сессия: долгий простой может потребовать ре-логина. Ничего другого не истекает.

> Профиль и загрузки - это ваши личные данные. Каталог `~/.config/yandex-messenger-mcp/` содержит живую сессию Яндекса: он не должен попадать в git, синхронизацию или бэкапы, из которых его кто-то достанет.

## Конфигурация

Файл: `~/.config/yandex-messenger-mcp/config.json`. **Целиком опционален** - без него всё работает на дефолтах. Переопределять можно точечно, любую секцию и любое поле.

| Ключ | Дефолт | Смысл |
|---|---|---|
| `paths.profileDir` | `profile` | Persist-профиль Playwright. Относительный путь резолвится от каталога конфига |
| `paths.downloadsDir` | `downloads` | Папка загрузок, туда же |
| `downloads.ttlDays` | `7` | TTL автоочистки загрузок. `0` или меньше - **выключить** подметание |
| `limits.listChatsDefaultLimit` | `50` | Дефолтный `limit` для `list_chats` |
| `limits.searchDefaultLimit` | `50` | Стартовый `limit` эскалации в `search` |
| `protocol.*` | см. ниже | Протокольные константы: на случай, если Яндекс их поменяет |

Секция `protocol` существует ради устойчивости к ротации: если новая версия `chats-web` уедет на другие хосты, их можно поправить в конфиге, не трогая код. Значения по умолчанию и полный пример - в [config.example.json](config.example.json).

Пример минимального конфига:

```json
{
  "downloads": { "ttlDays": 3 },
  "limits": { "listChatsDefaultLimit": 100 }
}
```

Уровень логов задаётся переменной `YANDEX_MESSENGER_MCP_LOG_LEVEL` (`debug`/`info`/`warn`/`error`, по умолчанию `info`). Лог идёт **в stderr**: stdout принадлежит MCP stdio-транспорту. Секреты сессии и содержимое переписки в логах редактируются.

## Подключение к MCP-клиенту

Сервер говорит по stdio. Путь до `dist/index.js` - абсолютный.

> **Таймаут вызова инструмента у MCP-клиента - минимум 5 минут.** Первый вызов, идущий к серверу, поднимает браузерный вход и **ждёт логина до 5 минут** (см. [Первый запуск](#первый-запуск-и-авторизация)). Дефолтные таймауты многих клиентов (30-60 секунд) короче этого ожидания и оборвут первый вызов молчаливым таймаутом ещё до того, как вы успеете войти - авторизация при этом выглядит «сломанной», хотя дело в таймауте. Поднимите таймаут вызова инструмента до >= 5 минут. Запас нужен и после первого входа: худший легальный read доходит до ~90 секунд, что тоже длиннее дефолтов.

### Claude Code

Через `npx` (основной способ, после установки пакета из npm - см. [Установка](#установка)):

```bash
claude mcp add yandex-messenger -- npx -y yandex-messenger-mcp
```

Из собранных исходников (разработка):

```bash
claude mcp add yandex-messenger -- node /абсолютный/путь/yandex-messenger-mcp/dist/index.js
```

### Claude Desktop

`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), через `npx`:

```json
{
  "mcpServers": {
    "yandex-messenger": {
      "command": "npx",
      "args": ["-y", "yandex-messenger-mcp"]
    }
  }
}
```

Из собранных исходников (разработка) - тот же конфиг с `command: "node"` и путём до `dist/index.js`:

```json
{
  "mcpServers": {
    "yandex-messenger": {
      "command": "node",
      "args": ["/абсолютный/путь/yandex-messenger-mcp/dist/index.js"]
    }
  }
}
```

Первый вызов любого инструмента откроет окно браузера для входа - это ожидаемо, см. [Первый запуск](#первый-запуск-и-авторизация).

## Автоочистка загрузок (TTL)

Скачанное вложение - это копия чужой переписки на диске, и она не должна жить вечно только потому, что агент один раз её открыл.

- **Что удаляется:** файлы в `downloads/`, у которых `mtime` старше TTL (дефолт 7 дней). Строго старше: файл ровно на границе остаётся.
- **Когда:** на старте сервера **и** перед каждым скачиванием. Второе важнее первого - сервер может месяцами не рестартовать, и старт как единственная точка очистки не сработал бы.
- **Границы:** только обычные файлы непосредственно в `downloads/`. Без рекурсии, без следования за симлинками, за пределы папки очистка не выходит.
- **Выключить:** `downloads.ttlDays: 0`. Это именно «не подметать», а не «удалить всё разом».

## Confirm-политика

Подтверждение (draft->confirm) стоит **только у необратимых** мутаций. Критерий один: откатывается ли последствие тем же инструментом за один вызов. Раньше необратимой была только отправка текста; с добавлением файлов, правки, удаления и голоса необратимых операций стало пять, и политика обобщена под общий критерий.

- **С confirm (двухшаговые, необратимые):** `send_message`, `send_file`, `delete_message`, `edit_message`, `vote_in_poll`. Сообщение или файл уходят живому собеседнику, правка перезаписывает текст, удаление стирает, голос не снимается - отменить одним вызовом нельзя.
- **Без confirm (одним вызовом, обратимые или безобидные):** `set_reaction` (`Action:REMOVE` откатывает тем же вызовом), `pin_message` (легко открепить), `mark_read` (безобидна). Read-пути (`get_*`, `search`, `list_chats`) и подписка на тред (`join_to_thread`/`leave_thread`) confirm тоже не требуют.

Почему confirm не раздан всем мутациям: дешёвый confirm обесценивает дорогой. Если подтверждать приходится и обратимую реакцию, его начинают жать не глядя - и тогда прожмут на `delete_message`. Подтверждение бережётся для того, что действительно необратимо.

Токен confirm несёт дискриминатор операции (`op`): токен, выданный одной операции, при предъявлении другой отвергается как `op_mismatch` - перепутать draft удаления с draft правки нельзя. На confirm заново резолвится чат и заново считается отпечаток нагрузки; любое расхождение - отказ, а не отправка «наиболее вероятного».

### Двухшаговая отправка (send_message как образец)

**Шаг 1 - draft.** Обычный вызов резолвит чат и возвращает превью:

```json
{
  "status": "draft",
  "chat_id": "...",
  "chat_name": "Имя чата",
  "text": "текст",
  "confirm_token": "...",
  "next_step": "Ничего не отправлено. ..."
}
```

В сокет не уходит **ничего**.

**Шаг 2 - confirm.** Повторный вызов с `confirm:true`, тем же `confirm_token` и **неизменёнными** `chat` и `text`.

Почему на confirm идёт повторная сверка, а не «отправить то, что в токене»: между draft и confirm может смениться всё. Тот же запрос `chat` завтра резолвится в другой чат - человек переименовался, появился однофамилец. Или к старому токену подставили другой текст. Поэтому токен несёт резолвнутый `ChatId` и хэш текста, на confirm чат резолвится и текст хэшируется **заново**, и результаты сверяются. Расхождение - отказ, а не отправка «наиболее вероятного».

Токен намеренно не подписан: подделывать его бессмысленно. Он не полномочие, а память о драфте - отправка всё равно идёт в заново проверенный чат.

Идемпотентность держится двумя слоями, потому что ретрая у отправки нет:

1. **Локально:** израсходованный токен возвращает запомненный результат, второй push не уходит.
2. **На сервере:** `PayloadId` фиксируется в токене на шаге 1, поэтому даже если локальная память потерялась (рестарт), повтор придёт как `DUPLICATE` - тоже успех, но нового сообщения не создаст.

Неоднозначный чат отправку **блокирует**: наружу уходят кандидаты, push не отправляется.

## Ограничения и пробелы в доказательствах

Раздел честный. Часть протокола подтверждена живыми прогонами, часть - только фикстурами по реверсу веб-клиента, и это разные уровни уверенности.

### Чего нет по решению (отложено)

- **Real-time.** Никаких подписок на LIVE-события: новые сообщения, typing, seen, presence. Инструмент отвечает на запрос, а не слушает поток. Бота и автоответы на нём не построить.
- **Отправка вложений - только image и file.** Картинки и произвольные файлы отправляются (`send_file`); **voice и gallery** отправлять нельзя (только читать). Голосовые и галереи **читаются**, но не создаются.
- **Мутации - частично.** Реализованы реверсибельные (реакции `set_reaction`, отметка прочтения `mark_read`, закреп `pin_message` - одним вызовом, без confirm) и необратимые (отправка файла `send_file`, правка `edit_message`, удаление `delete_message`, голос `vote_in_poll` - двухшаговые draft->confirm). Чтение опроса - `get_poll`. Пока НЕ реализованы: отправка voice/gallery, звонки, управление чатами, мульти-аккаунт.
- **Только cookie-авторизация.** OAuth не поддержан: в бандле это другой транспорт с другим форматом кадров, а не «второй режим», и он потребует отдельного WS-клиента.

### Что не подтверждено живьём

- **Сборка приватного `chat_id` подтверждена живьём** (2026-08-28, долг закрыт). Пара guid склеивается **отсортированной по кодовым единицам UTF-16**, а не в порядке «собеседник, я». Проверено на всей живой выдаче `list_chats`: 12 приватных чатов из 12 согласуются с сортировкой, причём представлены оба направления (у восьми собеседников guid больше моего, у трёх меньше, плюс self-чат). Прежняя конструкция промахивалась на тех чатах, где guid собеседника сортируется раньше моего - это и был баг [#16](https://github.com/conarti/yandex-messenger-mcp/issues/16). Резолв по имени прогнан живьём **на обоих направлениях** и в обоих случаях попал в цель.
- **Токен упоминания на исходящем подтверждён живьём; пинг адресату - нет** (2026-08-28). Отправка в self-чат с упоминанием, перечитанная с провода, несёт в сыром `MessageText` ровно один токен `@<guid>`, совпадающий с обогащённым `mentions[]`. Это закрывает форму. **Клиент адресата рендерит токен именем** - наблюдено на втором аккаунте 2026-08-28: упоминание пришло кликабельной ссылкой с отображаемым именем, а не сырым guid. Это закрывает половину баг-репорта #15 («выглядит как обычный текст, без ссылки на пользователя»). **Отдельного пуш-уведомления об упоминании в приватном чате не наблюдалось**, и это принято как рабочее поведение, а не как дефект. Рабочая гипотеза владельца: в приватном чате уведомление приходит на ЛЮБОЕ сообщение, поэтому упоминанию нечего добавить, и пинг имеет смысл только в групповом чате. Проверить это нельзя: групповые чаты выведены за границы живого тестирования. Формально это РЕШЕНИЕ, а не доказательство; если поведение окажется другим, заводится отдельный issue. См. [#15](https://github.com/conarti/yandex-messenger-mcp/issues/15).
- **Reply на исходящем подтверждён живьём; чтение пересылки - подтверждено, отправка пересылки - нет** (US-010 2026-07-21, дополнено спайком #22 2026-08-28). Отправка ответа (`reply_to_message_id`) даёт `Status:1`, и перечитывание возвращает `context.is_reply:true` с непустым `quotes` - цитата и признак ответа работают. **Чтение входящей пересылки закрыто живым кадром** ([`docs/spikes/v2/SPIKE-FORWARD-FRAME.md`](docs/spikes/v2/SPIKE-FORWARD-FRAME.md)): сиблинг `ForwardedMessages` приходит и в `history`, и в `message_info`, элемент имеет обёртку `{Payload, ServerMessageInfo}` (`Payload` - это тело), блок из нескольких пересылок приходит массивом. До этой правки обёртка была угадана неверно, и `forwarded[]` молча оставался пустым - это и был баг [#22](https://github.com/conarti/yandex-messenger-mcp/issues/22). **`ForwardedMessageRefs` не приходят вовсе** - ни у пересылки, ни у reply, поэтому `context` у пересылки не строится, и reply держится на `Quote`. **Отправка пересылки** (`forward_from`) по-прежнему не подтверждена: она уходит с `Status:1`, но на self-чате форма не отрендерилась, см. [issue #13](https://github.com/conarti/yandex-messenger-mcp/issues/13). Пересылка **с вложением** проверена живьём в двух видах: картинка и обычный файл. В обоих случаях `forwarded[].attachments[]` отдал реф с `file_id`, именем и размером, то есть формы `Payload.Image` и `Payload.MiscFile` подтверждены, обёртка элемента одна и та же.
- **Чтение вложений: `image`, `file` и `gallery` проверены живьём; `voice` - долг.** Скачивание типо-агностично (`voice`/`gallery_image` - это те же `file_id`, тянутся тем же generic-путём). Живьём (US-009) скачана **настоящая галерейная картинка** с аккаунта (`gallery_image`, непустые байты + `content_type`) - долг по галерее **закрыт**. **Реального голосового на профиле нет**, поэтому `voice` живьём по-прежнему не проверено - долг остаётся дословно, фикстура доказательством не объявляется.
- **Отправка вложений: путь не подтверждён до конца, но 403→квота поймана живьём** (US-010, 2026-07-21). 3 шага загрузки (`upload_to_disk` → PUT → `add_files`) и обычное сообщение с `Plain.Image`/`Plain.MiscFile` + `FileInfo.Id2` взяты из реверса веб-клиента: **входящие** вложения живьём наблюдались, а **исходящий** путь - нет. `Width`/`Height` картинки намеренно не проставляются (для доставки достаточно `file_info`). Живой прогон в self-чате упёрся в `upload_to_disk HTTP 403`, и код классифицировал его как **`upload quota`** (не generic «upload failed») - ветка **403→квота** подтверждена живьём. Полный трёхшаговый путь и ветка **413→размер** остаются доко-выведенными (заливка отклонена сервером до конца), см. [issue #5](https://github.com/conarti/yandex-messenger-mcp/issues/5). Билдеры в [`src/protocol/push.ts`](src/protocol/push.ts), загрузка в [`src/attachments/uploader.ts`](src/attachments/uploader.ts).
- **Форма ответа на отправку подтверждена живьём** (US-009). Успешный `push` текста вернул `{ Status:1, MessageInfo:{TimestampMcs, PrevTimestampMcs, SeqNo, Version}, DebugInfo }` - имена контейнера `MessageInfo`/`PrevTimestampMcs`/`TimestampMcs`/`SeqNo`/`Version` теперь **наблюдены**, а не реконструированы. `DebugInfo` (адреса/тайминги/попытки) сервер тоже отдаёт - не парсится, безвреден. `RateLimit` на успешной отправке **не приходит**. Парсер по-прежнему принимает оба написания; ответ без числового `Status` трактуется как отказ. Инвариант распространён и на путь вложений.
- **Постановка/снятие реакции подтверждены живьём** (US-009, долг закрыт). Постановка (`Reaction` **без поля `Action`**, серверный дефолт ADD) -> реакция появляется в [`list_reactions`](#list_reactions); снятие (`Action:REMOVE=1`) -> реакция исчезает; `Status:1` в обоих случаях. `REPLACE(2)` живьём не гонялся.
- **Голос `vote_in_poll`: отмена до нуля не подтверждена.** Форма `Vote{ChatId, Timestamp, Action:0, Choices}` (§9.3/§11.4) **закрыта живьём** (2026-07-17, `commit_status:1 FULLY_COMMITTED`): `Action:0` обязателен, `Results` не шлётся, `Choices` - 0-based индексы в `Poll.Answers[]`, полный набор выбора. Голос публичен и меняемый - повторная отправка заменяет выбор целиком (подтверждено повторным чтением: `my_choices` сменился с `[0]` на `[1]`). `form_status` в выдаче - `verified`, **AC-29 закрыт**. Confirm сохранён, потому что сам факт голоса необратим: `voted_count` растёт, а в не-анонимном опросе голосующий попадает в список голосовавших. Единственный оставшийся мелкий вопрос - **отмена голоса до нуля** (пустой `Choices` либо иной `Action`) протоколом не подтверждена (кнопки в веб-UI нет).

### Известные грубости

- **Единица `rate_limit.wait_for` неизвестна.** Ни в доке, ни в живом захвате она не встретилась (на успешной отправке `rate_limit` не приходит вовсе). Гадать не стали: сырое значение трактуется как миллисекунды и **зажимается в 1-60 секунд**. Кламп ограничивает ущерб при любой из трёх гипотез: если это секунды, минимум не даст устроить ретрай-шторм; если микросекунды, максимум не даст зависнуть на часы; если миллисекунды, значение проходит как есть. Точность здесь принесена в жертву осознанно. В лог пишется сырое значение - первый живой случай позволит определить единицу.
- **У поиска нет серверной пагинации.** Параметры `page`/`offset`/`from`/`skip` сервером игнорируются, поля `page`/`pages` в ответе вестигиальны (всегда 1), а `total` - это число **возвращённых** элементов, а не общее число совпадений. Полнота достигается эскалацией `limit`, то есть несколькими запросами вместо одного. Серверного потолка `limit` найти не удалось (1000 отвечает штатно), поэтому потолок эскалации клиентский: при упоре - `truncated:true`.
- **Автоматизация личного аккаунта.** Это личный инструмент на неофициальном протоколе. Риски по ToS вы принимаете на себя. Отправка не распараллеливается, массовых рассылок здесь нет.
- **Протокол может уехать.** Он снят с конкретной версии `chats-web`. Обновление веб-клиента может сломать инструмент; протокольные константы вынесены в `protocol.*` конфига, чтобы часть таких поломок чинилась без правки кода.

### Границы доверия

Сводка открытых пунктов из разделов выше - для быстрой сверки, без деталей (детали в тексте по ссылке-issue или в самом разделе).

| Пункт | Статус | Issue |
|---|---|---|
| Чтение `voice` не подтверждено живьём | долг | [#4](https://github.com/conarti/yandex-messenger-mcp/issues/4) |
| Разделение ошибок загрузки `507/403` (квота) / `413` (размер) не проверено живьём | долг | [#5](https://github.com/conarti/yandex-messenger-mcp/issues/5) |
| `REPLACE(2)` у реакции объявлен, но не используется и не наблюдался на проводе | долг | [#6](https://github.com/conarti/yandex-messenger-mcp/issues/6) |
| Отмена голоса в опросе до нуля не подтверждена (кнопки в UI нет) | долг | [#7](https://github.com/conarti/yandex-messenger-mcp/issues/7) |
| Единица `rate_limit.wait_for` неизвестна, сырое значение зажато в 1-60 секунд | долг | [#8](https://github.com/conarti/yandex-messenger-mcp/issues/8) |
| Обрезка цитаты reply до 200 символов (`QUOTE_MAX_LENGTH`, [`src/protocol/push.ts`](src/protocol/push.ts)) | выведено | - |
| `actors_complete` реакций в обогащении `get_history`/`get_message`/`get_message_context`/`get_thread` - сравнение `count` с числом акторов ([`src/protocol/enrichMessage.ts`](src/protocol/enrichMessage.ts)), случай расхождения живьём не наблюдался | выведено | - |
| **Форма выдачи изменилась: у удалённого сообщения `kind:'deleted'` вместо прежнего `'unknown'`.** `'unknown'` теперь означает строго «тела нет либо content-поля нет». Миграция - через поле `deleted`, оно не менялось | ломающее изменение | [#22](https://github.com/conarti/yandex-messenger-mcp/issues/22) |
| **Форма выдачи изменилась: `search` отдаёт сообщения обогащёнными**, как read-инструменты: `reads`, `mentions`, `reactions`, `thread`, `forwarded`, `from_me`. Прежде он отдавал голый v1-объект, и пересылка в его выдаче приходила пустой. Цена размера: пустое обогащение стоит около 116 байт на сообщение даже когда обогащать нечего, насыщенное - до килобайта (замер в [#19](https://github.com/conarti/yandex-messenger-mcp/issues/19)) | ломающее изменение | [#22](https://github.com/conarti/yandex-messenger-mcp/issues/22) |
| Разбор вложения в оригинале пересылки подтверждён живьём (2026-08-28) для ОБОИХ видов: `forwarded[].attachments[]` отдал `kind:'image'` и `kind:'file'` с `file_id`, именем и размером. Обёртка элемента одна и та же, отдельной ветки для файлов нет | наблюдено живьём | [#22](https://github.com/conarti/yandex-messenger-mcp/issues/22) |
| **Форма выдачи изменилась: отказ резолва чата несёт `reason`, `candidates[]` и `next_step`.** Литерал `status:'chat_not_found'` сохранён, поля добавлены. `reason` различает «имя не нашлось», «человек нашёлся, чата нет» и «бэкенд ответил `ENTITY_NOT_FOUND`». При причине «имя не нашлось» `candidates[]` пуст **структурно, всегда**: до этой ветки доходит только состояние с двумя пустыми бакетами, поэтому нагрузку там несёт `next_step`, а не список | ломающее изменение | [#18](https://github.com/conarti/yandex-messenger-mcp/issues/18) |
| **Форма выдачи изменилась: `search` отдаёт `chat_id` для найденного человека** плюс `chat_id_via:'user_search'`. Значение `guid` наблюдено сервером, а `chat_id` **сконструирован** из пары guid - метка это и фиксирует | аддитивное изменение | [#17](https://github.com/conarti/yandex-messenger-mcp/issues/17) |
| **Форма выдачи изменилась: `send_message` на draft отдаёт `text_preview`** (имена вместо guid, только для чтения) | аддитивное изменение | [#15](https://github.com/conarti/yandex-messenger-mcp/issues/15) |
| Поле `via` **не является сквозным дискриминатором** «наблюдено против сконструировано»: `toSelfChatCandidate` тоже конструирует `chat_id` из пары guid ([`src/chat/resolveChat.ts`](src/chat/resolveChat.ts)), но помечает его `via:'chat_search'`. Надёжен только `chat_id_via` в выдаче `search` | выведено | - |
| Отдельное пуш-уведомление об упоминании в приватном чате не наблюдалось. Форма токена на проводе подтверждена, **рендер у адресата наблюдён** (кликабельное имя вместо guid), а уведомление - нет. Принято как рабочее поведение по гипотезе «в приватном чате уведомляет само сообщение, пинг осмыслен только в групповом»; групповые чаты вне границ тестирования | решение, не доказательство | [#15](https://github.com/conarti/yandex-messenger-mcp/issues/15) |
| **`search` возвращает вас самих, а резолв чата - нет.** `toUserCandidate` ([`src/chat/resolveChat.ts`](src/chat/resolveChat.ts)) отбрасывает кандидата с вашим guid («сам себе собеседником не бываю»), потому что у резолва есть отдельная ветка self-чата. У `toUserHit` в [`search`](#search) такой ветки нет, и гард там сознательно не повторён: `chat_id` вида `<мой>_<мой>` - это корректный адрес self-чата. Итог: в выдаче `search` вы можете найти себя, в кандидатах резолва - нет | осознанное расхождение | [#17](https://github.com/conarti/yandex-messenger-mcp/issues/17) |
| **`edit_message` принимает состав упоминаний.** Поля `mentions` нет - поведение прежнее, упоминания цели читаются и переотправляются. Поле есть - это НОВЫЙ ПОЛНЫЙ состав: запросы резолвятся, токены подставляются в текст, старый состав не подмешивается. Пустой массив, в отличие от отсутствия поля, СТИРАЕТ упоминания, и отпечаток эти два случая различает | аддитивное изменение | [#15](https://github.com/conarti/yandex-messenger-mcp/issues/15) |
| Код `ENTITY_NOT_FOUND(4)` означает «чата нет», а не «по запросу нет данных»: проверено живьём на несуществующем чате против пустого ОКНА ДАТ в существующем. Случай «чат существует и пуст всю жизнь» отдельно не проверялся - такого чата в профиле не нашлось | наблюдено для пустого диапазона, выведено для пустого чата | [#18](https://github.com/conarti/yandex-messenger-mcp/issues/18) |

## Разработка

```bash
npm run build       # tsc
npm run typecheck   # tsc с тестами
npm test            # vitest, e2e при этом скипается
npm run test:watch
```

### E2E

Живой smoke-тест ходит на **реальный аккаунт** и по умолчанию **скипается**. Запуск явный:

```bash
YMCP_E2E=1 npm test
```

Он намеренно **только читает** (whoami, `list_chats`, `get_history`): ничего не отправляет и ничего не качает, чтобы его можно было безопасно гонять повторно. Ассерты идут только по числам и булям - живые данные не попадают ни в вывод, ни в диагностику падений.

TDQS

A3.9/5.0

Scored across 19 tools

Disambiguation4/5

Each tool has a distinct operational purpose, and the descriptions carefully clarify boundaries (e.g. full reactions vs. embedded reaction summaries, history pagination vs. context window). However, the cluster of message-reading tools (get_history, get_message_context, get_thread, list_reactions) could still cause some misselection without close reading.

Naming Consistency4/5

Most tools follow a clear verb_noun snake_case pattern: list_chats, send_message, delete_message, get_poll. Minor deviations like mark_read, vote_in_poll, and bare search break the strict pattern but are still predictable and readable.

Tool Count4/5

At 19 tools, the server is above the typical sweet spot but not bloated: each tool addresses a concrete messaging capability (chat listing, history, threads, reactions, polls, file handling, moderation). The breadth is justified by the messenger domain, though a few read variants could arguably be consolidated.

Completeness4/5

Core messaging lifecycles are covered: send, read, edit, delete, react, pin, search, poll voting, and attachment download. Notable gaps like creating chats, creating polls, forwarding messages, and sending voice/gallery media exist, but they are workable gaps for an assistant operating on existing conversations.

Maintenance

ActivityMaintained
ResponsivenessSlow