Skip to main content
Glama
seno76

tvtracker-mcp

by seno76
README.md
# tvtracker-mcp

**MCP-сервер, который находит сериал по описанию сюжета, ведёт прогресс просмотра
и отвечает на вопрос «что включить» одним вызовом.**

[![Python](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/downloads/)
[![MCP SDK](https://img.shields.io/badge/mcp--sdk-2.2-16637C)](https://py.sdk.modelcontextprotocol.io/)
[![Spec](https://img.shields.io/badge/spec-2026--07--28-16637C)](https://modelcontextprotocol.io/specification/2026-07-28)
[![Tests](https://img.shields.io/badge/tests-207-2C7A54)](#тестирование)
[![Coverage](https://img.shields.io/badge/coverage-91%25-2C7A54)](#тестирование)
[![License](https://img.shields.io/badge/license-MIT-blue)](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

A3.9/5.0

Scored across 5 tools

Disambiguation5/5

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.

Naming Consistency3/5

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.

Tool Count5/5

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.

Completeness3/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues