tvtracker-mcp
# tvtracker-mcp
**MCP-сервер, который находит сериал по описанию сюжета, ведёт прогресс просмотра
и отвечает на вопрос «что включить» одним вызовом.**
[](https://www.python.org/downloads/)
[](https://py.sdk.modelcontextprotocol.io/)
[](https://modelcontextprotocol.io/specification/2026-07-28)
[](#тестирование)
[](#тестирование)
[](LICENSE)
```
Ты: найди сериал, я не помню названия — там сотрудникам хирургически разделяют память
Severance (2022–, Apple TV+, идёт) · Drama/Science-Fiction/Mystery · 49 мин · 7.6
Mark Scout leads a team at Lumon Industries, whose employees have undergone a
severance procedure, which surgically divides their memories between their work…
→ отслеживаю, остановился на 2x03
```
Найдено за **один вызов** среди 89 664 сериалов. В TVmaze такого запроса нет вообще:
их API ищет только по названию.
---
## Зачем он нужен, если есть API
Обёртка транслирует эндпоинты в инструменты один к одному. Ценность такого сервера
нулевая: то же самое агент получил бы через generic-инструмент HTTP.
Сервер оправдан, только когда отвечает на вопросы, на которые **ни TVmaze, ни локальная
база поодиночке ответить не могут**. Здесь таких три:
| Вопрос | Почему API не отвечает | Кто считает |
| --- | --- | --- |
| «Найди сериал по описанию» | в TVmaze поиска по тексту описаний нет | FTS5 + BM25 по локальному корпусу |
| «Что у меня накопилось» | нужен личный прогресс, которого у API нет | пересечение трекинга с `airstamp` |
| «Стоит ли продолжать» | оценки серий лежат мёртвым грузом | агрегация рейтингов по сезонам |
Всё три считает наш код. Это и есть граница между продуктом и обёрткой.
## Установка
Нужен [uv](https://docs.astral.sh/uv/) и Python 3.11+. Ключ API не нужен: TVmaze отдаёт
данные без авторизации.
```bash
git clone https://github.com/seno76/tvtracker-mcp.git && cd tvtracker-mcp
uv sync --all-groups
cp .env.example .env
uv run tvtracker sync --full # корпус: ~380 запросов, около четырёх минут
uv run tvtracker status # сериалов: 89664
```
Подключение к хосту:
```bash
uv run mcp install src/tvtracker/server.py # Claude Code / Claude Desktop
uv run mcp dev src/tvtracker/server.py # MCP Inspector, посмотреть примитивы
```
Транспорт — stdio. Обслуживание базы вынесено в CLI: четыре минуты внутри вызова
инструмента — гарантированный таймаут у хоста.
---
## Инструменты (5)
Инструмент — это **сценарий, а не эндпоинт**. Типовой запрос закрывается за один-два
вызова; изменения состояния собраны в один инструмент с enum-действием вместо шести.
Все возвращают текст, а не сырой JSON, и **каждый сериал в выдаче помечен твоим
состоянием отслеживания** — этого в TVmaze нет ни при каком запросе.
### `show_search` — найти сериал
```python
show_search(query="", by="auto"|"title"|"description", genre=None, status=None,
language=None, year_from=None, year_to=None, min_rating=None,
limit=8, response_format="concise"|"detailed")
```
Три режима в одном инструменте, потому что для пользователя это один сценарий:
| Режим | Как вызвать | Что делает |
| --- | --- | --- |
| по названию | `query="Severance"` | точные и частичные совпадения, ранжирование по популярности |
| **по описанию** | `query="офис, где разделяют память"` | BM25 по очищенным аннотациям — главная функция сервера |
| по фильтрам | `query` пустой, `genre="Comedy"` | отбор каталога без поиска |
`by="auto"` решает сам: короткий запрос — название, фраза — описание. Если название не
нашлось, он пробует корпус, прежде чем сказать «ничего», — это экономит целый вызов.
<details>
<summary>Пример ответа</summary>
```
Severance (2022–, Apple TV+, идёт) · Drama/Science-Fiction/Mystery · 49 мин · 7.6
Mark Scout leads a team at Lumon Industries, whose employees have undergone a severance
procedure, which surgically divides their memories between their work and personal lives.
→ отслеживаю, остановился на 2x03
I Need Romance (2011–2014, tvN, завершён) · Drama/Comedy/Romance · 60 мин · 6.8
A drama about the lives of employees at a home shopping company and the love between
friends in their 30s.
→ брошен (1x04)
Это лучшие совпадения по описанию, а не полный список.
Сузить: genre="Science-Fiction" или year_from=2020.
```
Шесть полей на сериал, а не сорок. `image`, `_links`, `externals` отбрасываются при
загрузке и физически не могут попасть в контекст.
</details>
### `show_profile` — расскажи про сериал
```python
show_profile(show, response_format="concise"|"detailed")
```
Агрегат четырёх эндпоинтов TVmaze плюс два блока, которых в API нет: **рейтинговый
профиль по сезонам** и **позиция пользователя**. Обёртка потратила бы здесь четыре
вызова и не дала бы ни того ни другого.
<details>
<summary>Пример ответа</summary>
```
Severance (2022–, Apple TV+, идёт) · Drama/Science-Fiction/Mystery · 49 мин · 7.6
Mark Scout leads a team at Lumon Industries, whose employees have undergone a severance
procedure, which surgically divides their memories between their work and personal lives.
→ отслеживаю, остановился на 2x03
Рейтинг по сезонам — с1: 8.1 (7) · с2: 7.7 (5). Тренд: проседает.
Ты на 2x03. Впереди 2 серии, ~1 ч 40 мин.
```
Строка «с1: 8.1 · с2: 7.7 · тренд проседает» — это и есть то, ради чего затевалась
агрегация: без неё формулировка «перетерпи до 3x05» невозможна.
</details>
### `episode_search` — найти серию по описанию
```python
episode_search(query, show=None, season=None, include_specials=False,
include_unwatched=False, limit=5)
```
«Серия, где они застряли в лифте» — запрос, на который TVmaze не отвечает. Работает по
кэшу эпизодов, то есть по отслеживаемым сериалам; если сериал указан, а эпизодов нет,
подгружает их на лету.
**Защита от спойлеров.** По умолчанию описания серий *после* текущей позиции не
показываются — только факт наличия. Такого поведения у обёртки не может быть в принципе:
оно требует знания прогресса.
<details>
<summary>Пример ответа: пользователь на 2x03</summary>
```
Severance 2x01 «Season Two, Part 1» — The severed floor deals with the aftermath.
Severance 2x02 «Season Two, Part 2» — The severed floor deals with the aftermath.
Severance 2x03 «Season Two, Part 3» — The severed floor deals with the aftermath.
Severance 2x04 «Season Two, Part 4» — ты до неё ещё не дошёл, описание скрыто
(include_unwatched=true покажет).
Severance 2x05 «Season Two, Part 5» — ты до неё ещё не дошёл, описание скрыто.
```
Граница проходит ровно по позиции: просмотренное показано, непросмотренное скрыто, но
о его существовании сказано честно.
</details>
### `tracking_update` — изменить состояние просмотра
```python
tracking_update(show, action="track"|"untrack"|"progress"|"rate"|"pause"|"finish",
episode=None, rating=None, note=None)
```
Один инструмент вместо шести. `episode` принимает человеческий формат `"2x03"`
(кириллическая «х» тоже). При `action="track"` эпизоды сериала загружаются и индексируются.
Ответ подтверждает изменение и **сразу даёт следующий шаг** — иначе «отметил серию»
превращается во второй вызов «а что дальше»:
```
Отмечено: Severance 2x04. Дальше — 2x05, «Season Two, Part 5», 50 мин, вышла.
```
Единственный инструмент, помеченный как изменяющий данные (`readOnlyHint = false`).
После него сервер шлёт уведомление об изменении ресурсов, и хост перечитывает их сам.
### `watch_next` — что включить
```python
watch_next(minutes=60, include_new=False, limit=3)
```
Не поиск, а **решение**. Берёт бэклог, фильтрует по свободному времени (с запасом 10 %),
ранжирует по своим оценкам и размеру долга. Тот вызов, ради которого затевается сервер:
вопрос «что посмотреть» закрывается одним обращением, без предварительных поисков.
<details>
<summary>Пример ответа</summary>
```
Из накопившегося за 60 мин:
Severance 2x04 «Season Two, Part 4» — 50 мин, накопилось 2, твоя оценка сериала 9
The Bear 3x09 «Course 9» — 30 мин, накопилось 2, твоя оценка сериала 7
```
Если ничего не укладывается — **одна** альтернатива, а не список: список из шести
вариантов возвращает пользователя ровно в ту растерянность, из-за которой он и спросил.
</details>
---
## Ресурсы (5)
Ресурс — данные, которые подаёт **приложение**, а не добывает модель. Критерий включения
один: содержимое нужно в контексте *до* того, как модель начала рассуждать. Всё, что
«может понадобиться, а может нет», — инструмент.
Общие решения:
- **`text/plain`, а не JSON.** JSON тратит на скобки и повторяющиеся имена полей
в полтора-два раза больше токенов при той же информации, а читает это только модель.
- **Всё считается из локальной БД.** Ответ за миллисекунды, работает даже когда TVmaze лежит.
- **Бюджет — 275 токенов на все пять** при ориентире дизайна ~1100. Это цена, которую
платишь в каждом запросе, где хост их подставил.
Шаблонных ресурсов (`show://{slug}`) в дизайне сознательно нет: данные по конкретному
сериалу нужны не всегда, а по запросу — это работа `show_profile`.
### `tracking://watching` — что я смотрю сейчас
Профиль пользователя в этом домене. Любой вопрос про сериалы требует его; оформив это
инструментом, вы обязали бы модель догадаться сходить за историей.
```
Смотрю сейчас (3):
Severance — 2x03, обновлено 2026-09-08, моя оценка 9
The Bear — 3x08, обновлено 2026-09-08, моя оценка 7
Slow Horses — 4x06, обновлено 2026-09-08, моя оценка 8
На паузе (1): Foundation (2x04)
```
### `tracking://backlog` — накопившиеся серии
**Сердце проекта.** Такого ответа нет ни в TVmaze, ни в локальной базе: он рождается на
их пересечении — позиция из трекинга минус вышедшие по `airstamp` серии, с отброшенными
спецвыпусками и суммированием длительности.
```
Накопилось (по свежести выхода):
Severance — 2 сер., 1 ч 40 мин, свежая вышла 2 дн. назад, 2x04, 2x05
The Bear — 2 сер., 1 ч, свежая вышла 4 нед. назад, 3x09, 3x10
Slow Horses — 0 серий, новых серий нет
Итого: 4 сер., 2 ч 40 мин. Спецвыпуски не учитываются.
```
Строка «0 серий, новых серий нет» — тоже ответ: видно, ждать ли продолжения.
<details>
<summary>Граничные случаи, каждый под тестом</summary>
- сериал закончился и долгов больше не будет;
- серия вышла сегодня, но по UTC ещё не наступила — поэтому берётся `airstamp`, а не `airdate`;
- `runtime = null` → падаем на `averageRuntime` сериала, а при отсутствии обоих не выдумываем;
- дейли-шоу с нумерацией по годам (`season: 2026, number: 178`);
- пропущенный сезон — это долг, а не повод считать сериал досмотренным;
- спецвыпуски и `insignificant` в долг не идут.
</details>
### `tracking://schedule` — календарь моих сериалов
Единственный из пяти, который меняется **сам по себе**, без действий пользователя.
Модель не может знать, что его стоит перепроверить, — значит подавать его должен хост.
```
Ближайшие 14 дней (сегодня 2026-09-08):
сб 12.09 Severance 2x06 «The Next One»
пн 14.09 Slow Horses 4x07 «Cold Water»
```
### `tracking://taste` — профиль вкуса
Основание любой рекомендации. Если добывать его инструментом, модель будет то запрашивать
его, то нет, и качество советов станет случайным от разговора к разговору.
```
Профиль вкуса (по 6 сериалам, из них 6 с оценкой):
Люблю: Drama 7.6 (5)
Прохладно: Comedy 7 (3)
Досматриваю: 67% из закрытого.
Длина: предпочитаю 30–60 мин.
Площадки: Apple TV+ (2), Channel 4 (1), FX (1)
Языки: English (5), Korean (1)
Последние оценки: Severance 9, The Bear 7, Shogun 9, Slow Horses 8, I Need Romance 5
```
При выборке меньше пяти оценок добавляется строка «выборка мала — считай эти цифры
наброском». Без неё модель выдаёт статистику из четырёх сериалов за устойчивый вкус.
### `catalog://facets` — допустимые значения фильтров
```
Значения для фильтров show_search:
genre: Drama, Comedy, Romance, Crime, Action, Adventure, Mystery, Anime, History, …
status: Ended | Running | To Be Determined | In Development
type: Scripted | Documentary | Reality | Animation | Variety | Talk Show | Game Show
language: English (44.9k), Russian (6.4k), Japanese (6.2k), … всего 30
Жанры чувствительны к написанию: "Science-Fiction", не "Sci-Fi" и не "sci fi".
```
Последняя строка — не украшение, а профилактика самой частой ошибки. Без справочника
модель угадывает написание, получает пустую выдачу и делает ложный вывод «в каталоге
ничего нет».
---
## Промпты (4)
Промпт задаёт рассуждению рамку целиком; инструмент решает подзадачу внутри него.
Общее правило: **промпты втягивают контекст из ресурсов, а не добывают его
инструментами.** Шаблон, начинающийся с «сначала вызови такой-то инструмент, чтобы
узнать историю», означал бы, что примитивы разложены неверно.
| Промпт | Аргументы | Втягивает | Инструментов | Отвечает |
| --- | --- | --- | :---: | --- |
| `whats_next` | `minutes`, `mood?` | backlog, watching, taste | ≤ 1 | 2–3 варианта, по строке обоснования |
| `catch_up` | `since?` | backlog, schedule, watching | 0 | что вышло / что впереди / **один** совет |
| `find_by_vibe` | `description` | facets, taste | 1–2 | 3–5 кандидатов либо один уточняющий вопрос |
| `season_verdict` | `show` | watching, taste | ровно 1 | «продолжай» / «перетерпи до N» / «бросай» |
Два решения, которые стоит заметить. `catch_up` требует **ровно один** совет, а не
список: список из шести вариантов возвращает пользователя в ту же растерянность.
`season_verdict` прямо разрешает ответ «бросай» и запрещает подслащивать — доля
брошенного в профиле показывает, что пользователь и сам так делает.
---
## Архитектура
Смысл разделения один: доменную логику можно проверить на выдуманных данных за
миллисекунды, а транспорт поменять со stdio на HTTP, не тронув её ни на строку.
Импорт разрешён только вниз; правило проверяется прогоном по AST.
```
7 server.py, cli.py точки входа
6 tools/, resources.py, слой MCP: регистрация примитивов, никакой логики
prompts.py
5 runtime.py как обработчики добираются до зависимостей
4 mapping.py, services.py строки БД → доменные структуры
3 storage/ SQLite: схема с FTS5, репозиторий, синхронизация
2 tvmaze/, index/ источник и полнотекстовый поиск
1 domain/ чистая логика: бэклог, вкус, вердикт, рендер
0 config, errors, log, основание
clock
```
Доменный слой не знает ни про SQL, ни про сеть, ни про MCP, ни про системные часы:
«сейчас» приходит аргументом — только поэтому случай «серия вышла сегодня, но по UTC
ещё не наступила» вообще поддаётся проверке.
Подробнее — [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
### Данные
| Таблица | Что хранит |
| --- | --- |
| `shows` + `shows_fts` | корпус ~90k сериалов, FTS5 внешнего содержимого |
| `episodes` + `episodes_fts` | **лениво**: только для отслеживаемых сериалов |
| `tracking` | позиция, состояние, оценка, заметка |
| `sync_state` | метки последней синхронизации |
Корпус полный, эпизоды ленивые. 90k сериалов с описаниями — это один обход и ~77 МБ;
эпизоды для всех — миллионы строк. Классический just-in-time retrieval: серии тянутся
в момент `tracking_update(action="track")`.
### Политика бюджета контекста
1. Список эпизодов целиком не отдаётся **никогда** — только окно вокруг позиции.
2. `image`, `_links`, `url`, `externals` отбрасываются при загрузке, а не при рендере.
3. `summary` чистится от HTML и режется до ~200 символов в concise-режиме.
4. Любое усечение сопровождается счётчиком и конкретной подсказкой, чем сузить.
5. Целевой размер concise-ответа — до ~600 токенов.
---
## Тестирование
```bash
uv run pytest -m unit -q # домейн, миллисекунды, без сети и без БД
uv run pytest -q --cov # весь набор с покрытием
uv run ruff check . && uv run mypy
```
**207 тестов, покрытие 91 %** (`domain/` — 95–98 % при пороге 90 %). Сети нет ни на одном
уровне: HTTP мокается через [respx](https://lundberg.github.io/respx/), включая
искусственный 429 для проверки backoff и потолка попыток.
| Маркер | Что покрывает | Шт. |
| --- | --- | ---: |
| `unit` | чистый домейн на фикстурах | 127 |
| `integration` | SQLite в `tmp_path`, клиент через respx, рендеры ресурсов | 43 |
| `contract` | примитивы MCP через in-memory клиент SDK | 24 |
| `regression` | закреплённые сценарии evals и сам прогонщик | 13 |
Контрактные тесты подключают `Client` прямо к объекту сервера — без подпроцесса и порта
([документация SDK](https://py.sdk.modelcontextprotocol.io/get-started/testing/)).
## Оценка качества
```bash
uv run python -m evals.runner # 12 сценариев, отчёт в evals/reports/
```
**12 из 12** сценариев проходят, все пороги дизайна выполнены:
| Порог | Значение | Факт |
| --- | --- | --- |
| Типовой сценарий | 1–2 вызова | медиана 1 |
| Ответ concise | до ~600 токенов | максимум 351 |
| Сериал с 450 сериями | до 2000 токенов | 195 |
| Все пять ресурсов | ~1100 токенов | 275 |
Прогон измеряет **цену намеченного плана вызовов**, а не то, угадает ли модель этот план:
для второго нужен агент в петле. Разбор всех правок, сделанных по числам, — таблица
«было/стало» в [`docs/EVALS.md`](docs/EVALS.md).
---
## Отказы и ограничения
**Ошибки чинят следующий вызов.** Не «404», а что именно не так и что подставить:
```
По запросу «Zzqqwx» ничего не найдено.
Или опиши сюжет — я умею искать по описанию (by="description").
Ожидаю "2x03" или season=2, number=3. Получено "S02E03".
Отмечено: Arrested Development. Список серий подтянуть не удалось: источник временно
ограничил доступ. Сама подписка сохранена, повтори через ~10 секунд.
```
**Лимит источника.** TVmaze разрешает ~20 запросов за 10 секунд на IP. Клиент разносит
запросы (1.8 rps), на 429 уходит в экспоненциальный backoff с джиттером и уважает
`Retry-After`; число попыток ограничено явно, бесконечного ретрая нет.
**Непрямая инъекция промпта.** Описания сериалов и серий на TVmaze пишут пользователи, и
этот текст попадает в контекст модели. Меры: очистка HTML, жёсткое усечение, обрамление
маркером источника. **Полностью проблема не решается** — это ограничение, которое надо
знать. Не давайте серверу инструментов с побочными эффектами вне трекинга.
**Прочее по безопасности.** SQL только параметризованный; строка пользователя
экранируется перед попаданием в FTS5-запрос, так что операторы `NEAR`, `*`, `OR` не
выполняются; путь к БД берётся из конфига без интерполяции ввода.
## Что не сделано
Честный список, а не умолчание:
- **Неделя реального использования** — единственная проверка из дизайна, которую не
заменяет ни один прогон: какие вопросы задаются на самом деле.
- **`sync --updates` не гонялся против живого API** — покрыт только моками. Полный обход
при первом реальном запуске вскрыл коллизию slug, которую моки не ловили.
- **Нет CI и pre-commit** — контур гоняется вручную.
- **Python 3.12 не проверялся**, хотя `requires-python = ">=3.11"` его обещает.
- **Опционально по дизайну:** гибридный поиск (`fastembed` + `sqlite-vec`, слияние с BM25
через RRF) и Streamable HTTP.
## Документация
| Файл | О чём |
| --- | --- |
| [`docs/LAYOUT.md`](docs/LAYOUT.md) | что где лежит и зачем — разбор каждого файла и формата |
| [`docs/DESIGN.md`](docs/DESIGN.md) | дизайн примитивов и обоснование каждого решения |
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | карта слоёв, правило зависимостей, что где тестируется |
| [`docs/IMPLEMENTATION_PLAN.md`](docs/IMPLEMENTATION_PLAN.md) | этапы, система логирования, регрессионный контур |
| [`docs/EVALS.md`](docs/EVALS.md) | замеры, таблица «было/стало», разбор правок |
## Стек
Официальный [Python SDK для MCP](https://py.sdk.modelcontextprotocol.io/) 2.x, `httpx`,
`pydantic-settings`. SQLite с FTS5 — из стандартной библиотеки, отдельной поисковой
системы не требуется. Разработка: `pytest`, `respx`, `ruff`, `mypy --strict`.
## Атрибуция
Данные о сериалах предоставлены [TVmaze](https://www.tvmaze.com/) и распространяются по
лицензии [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/). Этот проект
использует их на тех же условиях и ссылается на TVmaze как на источник.
API: <https://www.tvmaze.com/api>.
## Лицензия
MIT для кода — см. [LICENSE](LICENSE). Данные — CC BY-SA (см. выше).
TDQS
Scored across 5 tools
Each tool targets a distinct workflow stage: show discovery, show profile viewing, episode search within tracked shows, tracking state updates, and next-episode recommendation. The descriptions clearly separate show-level search from episode-level search, so there is little risk of selecting the wrong tool.
show_search and episode_search follow a consistent entity_search pattern, but show_profile uses entity_noun, tracking_update uses action-based naming, and watch_next is a verb phrase. All names are readable, but the set mixes naming conventions rather than following a uniform verb_noun or object_action pattern.
Five tools is well-scoped for a personal TV tracking server: discovery, detail, search, state mutation, and recommendation are each covered by one focused tool. No tool feels redundant or missing from the core set.
The main tracking workflow is mostly covered, but there is no explicit way to list all tracked shows, which is a notable gap for a tracking server. An agent would have to rely on broad searches or the watch_next recommendation to reconstruct the user's watchlist, creating potential dead ends.