Tutu MCP
by nyxandro
README.md
# Туда, куда не ищется
Собираем поездку через пересадку, когда прямого билета нет.
Поиск любого сервиса отвечает на вопрос «есть ли билет из А в Б». Если прямого
сообщения нет, выдача пустая — и человек уходит. Хотя доехать можно, и оба
нужных билета продаются там же.
**Живой пример.** Запрос «Москва → Углич» на 20 августа 2026 в MCP Туту:
```
search_multitransport → 0 вариантов
search_bus → 0 вариантов
search_rail → 0 вариантов
```
Наш ответ на тот же запрос:
```
Москва → Ярославль автобус 09:00 → 13:00 863 ₽
пересадка в Ярославле — 40 минут
Ярославль → Углич автобус 13:40 → 16:25 616 ₽
итого 1 479 ₽, на месте в 16:25
```
Оба билета продаёт Туту. Их поиск этого маршрута не покажет никогда.
## Как это работает
```
запрос «Москва → Плёс»
↓
MCP Туту: прямой поиск → 0 вариантов, регион «Ивановская область»
↓
узлы-кандидаты: справочник «регион → центр» + предложения агента
↓
для каждого узла: сначала плечо «узел → цель», затем «откуда → узел»
↓
код сводит стыковки: отбрасывает рейсы, на которые не успеть, считает запас и сумму
↓
карточки маршрутов
```
Поиском управляет агент: он сам решает, какие города и даты проверять, и
человек видит ход его работы лентой на экране. Но **стыковки, цены и
ранжирование считает код** — модель к арифметике не допущена, именно там
агенты ошибаются. Тяжёлые ответы Туту через модель не проходят: она получает
идентификаторы найденных плеч и короткие сводки, а полные данные уходят прямо
в интерфейс.
Если уехать в выбранный день нельзя, бэкенд досылает агента за гостиницами:
ищется ближайший день отъезда и подбирается ночлег ровно на столько ночей,
сколько ждать.
Если агент недоступен или упёрся в лимит, интерфейс молча переключается на
детерминированный поиск — экран показывает результат в любом случае.
## Запуск
```bash
npm install
cp .env.example .env # вписать OPENROUTER_API_KEY (необязательно)
npm run db:up # Postgres в Docker
npm run migrate # схема базы
npm run warm # прогрев кэша реальными ответами Туту
npm run dev # http://localhost:3100
```
`OPENROUTER_API_KEY` опционален: без него подсказка городов отключается,
поиск работает на справочнике регионов.
## Команды
| Команда | Что делает |
| --- | --- |
| `npm run dev` | приложение на http://localhost:3100 |
| `npm run route -- Москва Углич 2026-08-20` | сборка маршрута в консоли |
| `npm run warm` | прогрев кэша из `src/modules/tutu/fixtures/` |
| `npm run cache` | что сейчас в кэше |
| `npm run smoke` | проверка живой связки с MCP Туту |
| `npm run db:up` / `db:down` | Postgres в Docker |
| `npm run migrate` | миграции Prisma |
| `npm run lint` | ESLint |
## Структура
Весь код в `src/`, в корне только конфигурация. Интерфейс собран одной папкой,
логика разложена по модулям — по смыслу, а не по типу файла:
```
src/
app/ роутинг Next: страница и три API — тонкие, только вызовы модулей
api/agent/ агентный поиск потоком
api/route/ детерминированный поиск (страховка)
api/stay/ гостиницы
frontend/ всё, что видно в браузере
components/route/ форма, календарь, подсказки, лента, карточки, гостиницы
hooks/ чтение потока агента, определение города
design.ts палитра и подписи из макета
format.ts, geo.ts форматирование и расстояния
config.ts константы интерфейса
modules/ логика, зависимости однонаправленные: agent → routing → tutu
tutu/ клиент MCP, кэш, типы, прокси, снятые ответы (fixtures)
routing/ сборка маршрутов, справочники регионов и городов
agent/ цикл, инструменты, промпт, ошибки, ретраи, состояние
lib/ клиент Prisma
```
**Почему внутри модулей нет слоёв** `domain/`, `infra/`, `api/`. Такая нарезка
оправдана на десятках тысяч строк и полутора десятках доменов. Здесь один
сценарий: слои дали бы папки по одному файлу и пустой `contract/`. Когда модуль
перерастёт тысячу строк, они появятся вокруг существующих файлов.
## Работа с MCP Туту
Подключение: `https://mcp.tutu.ru/mcp`, streamable HTTP, авторизация не нужна.
Два инженерных решения, без которых это не работает:
**Ответы весят ~26 КБ.** Инструменты обёрнуты так, что полный JSON уходит в
интерфейс и рисуется карточками, а в языковую модель — сжатая выжимка через
`toModelOutput`. Замер на реальном поиске: 26 305 → 1 971 символ, экономия 93 %.
**У сервера жёсткий rate limit.** Около полусотни запросов подряд закрыли нам
доступ ко всему домену на уровне TCP на несколько часов. Поэтому: `view: 'compact'`,
кэш ответов в Postgres, не больше двух узлов на поиск, а перед демо — прогрев
кэша из `fixtures/` командой `npm run warm`. Если сервер недоступен, экран
показывает то, что есть в кэше, и не падает.
## Стек
Next.js 16 (App Router) · TypeScript · `@ai-sdk/mcp` · Prisma 7 + Postgres 18 ·
Tailwind 4 + shadcn/ui · OpenRouter (опционально)
## Документы
- `SPECIFICATION.md` — продукт целиком: проблема, доказательства, техника, регламент
- `SCREEN-SPEC.md` — спецификация экрана для вёрстки
- `USER-GUIDE.md` — руководство пользователя
- `fixtures/README.md` — что за сохранённые ответы и зачем
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues