Skip to main content
Glama

Заезд

Движок поездок, который начинается с повода. Человек не знает, куда ехать, он знает, зачем: хочет на конференцию по своей теме. Заезд находит ближайшее офлайн-событие, считает дорогу туда и обратно, отель рядом с площадкой и полную цену участия, и показывает до трёх объяснимых пакетов на одном экране.

Первая вертикаль - ИТ-конференции. Каталог событий даёт confcal MCP, транспорт и жильё - Tutu MCP, а собирает всё детерминированный композитор, а не модель.

Что где

Что

Где

Экран поездки

/ на том адресе, где вы запустили Заезд

Поездка по ссылке

/t/<trip_id>, ссылку отдаёт экран и любой из инструментов

MCP-эндпоинт

/mcp, streamable HTTP, без ключа

Проверка живости

/healthz, отвечает режимом и опорной датой

Руководство пользователя

docs/user-guide.md

Двухминутная запись

docs/demo.mp4, сервер, экран и агент

Related MCP server: trip-planner-mcp

Сквозной сценарий

Открываете корень. На экране уже собранная поездка, а не пустая форма. Сверху событие, дата, город, площадка и время пешком до неё от ближайшего отеля. Ниже до трёх карточек: дорога туда с номером поезда или рейса, отель с ценой за всё проживание и расстоянием до площадки, дорога обратно, итог одной цифрой и строка арифметики под ним, которая сходится. Дальше карта города с площадкой и отелями, погода на даты поездки, другие события по теме и список того, что не вошло и почему.

Нажимаете "Собрать ссылки на оплату" - появляется чек-лист из двух-трёх ссылок Туту. Подпись каждой берётся из того, что Туту реально вернул: "Открыть корзину" там, где откроется корзина, и "Открыть страницу выбора" там, где корзины не будет.

То же самое агенту: find_event_trips с темой и городом отправления возвращает ту же поездку структурой, get_trip_details раскрывает пакет, create_trip_checkout собирает чек-лист. Хост, который умеет рисовать, получает тот же экран как MCP App.

Запуск из исходников

npm install
ZAEZD_MODE=replay npm run dev

Откроется http://localhost:8080. В режиме replay продукт работает на записанных ответах из fixtures/ и не ходит в сеть: с ним можно жить без интернета и без ключей, а опорной датой служит день записи. Живой режим - ZAEZD_MODE=live.

Проверка целиком:

npm run verify

Это синхронность правил, сверка документированных команд, типизация, линтер, сборка браузерного дерева, исполняемые сценарии на Gherkin и юнит-тесты. Всё офлайн.

Браузерное дерево собирается до сценариев не для красоты. Один сценарий запрашивает у сервера тот самый boot.js, который уезжает в хост, и на чистом клоне этого файла ещё нет.

Запуск в контейнере

Образ собирается из репозитория и внутри содержит и код, и фикстуры, поэтому в режиме replay контейнеру не нужен ни интернет, ни секреты.

cp .env.example .env      # и отредактируйте
docker compose up -d --build

Экран поднимется на http://127.0.0.1:8080. Без файла .env compose не стартует: это намеренно, чтобы никто не запустил продукт на чужих значениях по умолчанию.

Без compose то же самое одной командой:

docker build -t zaezd .
docker run -d --name zaezd -p 127.0.0.1:8080:8080 --env-file .env zaezd

Разово, без файла окружения, чтобы просто посмотреть:

docker run --rm -p 127.0.0.1:8080:8080 \
  -e ZAEZD_MODE=replay -e ZAEZD_PUBLIC_URL=http://localhost:8080 zaezd

Переменные окружения

Полный список с комментариями лежит в .env.example. Коротко:

Переменная

Зачем

По умолчанию

ZAEZD_MODE

live ходит в источники, replay читает fixtures/

live

PORT

порт внутри контейнера

8080

ZAEZD_PUBLIC_URL

адрес, по которому продукт виден снаружи. Из него строятся ссылки на поездку и CSP виджета

http://localhost:8080

ZAEZD_CONTACT_EMAIL

контакт в User-Agent; Nominatim банит анонимные запросы

zaezd@example.com

ZAEZD_CONFCAL_URL

MCP каталога событий

публичный confcal

ZAEZD_TUTU_URL

MCP Туту

https://mcp.tutu.ru/mcp

Ключей и токенов нет ни одного: оба источника открытые. Значение ZAEZD_PUBLIC_URL важно задать честно - именно этот адрес уезжает агенту как ссылка на экран и попадает в CSP виджета, и если он не совпадает с реальным, доска внутри хоста останется пустой.

Контейнер несёт HEALTHCHECK на /healthz, так что docker ps показывает healthy только когда приложение действительно отвечает.

За обратным прокси

Сам docker-compose.yaml ничего не знает про прокси, домены и сети - это свойства площадки, а не продукта. Всё это кладётся в docker-compose.override.yaml, он в гитигноре и живёт только на той машине, которая разворачивает сервис. Пример для Traefik:

services:
  zaezd:
    ports: !reset []          # наружу светит прокси, а не контейнер
    environment:
      ZAEZD_PUBLIC_URL: https://zaezd.example.com
    labels:
      traefik.enable: 'true'
      traefik.http.routers.zaezd.rule: Host(`zaezd.example.com`)
      traefik.http.routers.zaezd.entrypoints: https
      traefik.http.routers.zaezd.tls: 'true'
      traefik.http.routers.zaezd.tls.certresolver: letsencrypt
      traefik.http.services.zaezd.loadbalancer.server.port: '8080'
    networks: [proxy]

networks:
  proxy:
    external: true

Имена точки входа, резолвера сертификатов и сети у всех разные, поэтому в примере они подставные. Для nginx или Caddy override будет другим, продукт от этого не меняется: он слушает PORT и отдаёт /healthz.

Подключить к агенту

Эндпоинт <адрес>/mcp работает по streamable HTTP и не требует ключа. Локально это http://localhost:8080/mcp, на развёрнутом сервисе - ваш адрес из ZAEZD_PUBLIC_URL.

Claude Desktop не принимает удалённые серверы прямо в claude_desktop_config.json, там живут только stdio-команды, поэтому либо добавьте Заезд через Settings, Connectors, Add custom connector, либо пропишите проксирующий запуск:

{
  "mcpServers": {
    "zaezd": { "command": "npx", "args": ["-y", "mcp-remote", "http://localhost:8080/mcp"] }
  }
}

Claude Code и Codex CLI:

claude mcp add --transport http zaezd http://localhost:8080/mcp
codex mcp add zaezd --url http://localhost:8080/mcp

Qwen Code:

qwen mcp add --transport http zaezd http://localhost:8080/mcp

Хост, который умеет рисовать виджеты, получит тот же экран через ресурс ui://zaezd/trip-board. Хост без виджетов получит тот же ответ текстом.

Архитектура

Зависимости идут только вниз.

Слой

Где

Что делает

L0

src/composer/{types,dates,selection,feasibility,pricing,hotels,packages,checkout-labels}.ts

чистые правила: даты, выполнимость, цена, отбор пакетов. Без ввода-вывода и без часов

L1

src/sources/

клиенты confcal и Tutu, нормализация, кэш, режим replay

L2

src/enrich/

геокодинг, производственный календарь, погода. Каждый с таймаутом и запасным ответом

L3

src/composer/{build-trip,build-checkout,trip-id}.ts

сборка поездки, бюджеты, живые ссылки на оплату, запрос в ссылке

L4

src/web/, src/mcp/

экран и три инструмента. Бизнес-логики здесь нет

Схема и две последовательности - в docs/architecture.md, решения с обоснованиями - в docs/decisions.md.

Ключевое: даты, цены и выполнимость считает код, а не модель. Три одинаковых живых прогона через модель дали три разных числа ночей и разброс цены в полтора раза. Поэтому алгоритм живёт в src/composer/dates.ts и покрыт таблицей сценариев.

Ограничения

Список честный, читайте его как часть продукта.

  • Онлайн-событие поездку не строит, и событие в вашем же городе тоже. В обоих случаях Заезд говорит об этом прямо, а не показывает пустой экран;

  • живой каталог confcal знает 21 город, а живые офлайн-события есть примерно в 15 из них. Пустые города не прячутся, счётчик показан на экране;

  • за один запрос считается одна поездка. Ещё до пяти событий по теме показываются списком, но не считаются: веер из пяти сборок - это спиннер, а не продукт;

  • прогноз погоды доступен только на 16 дней вперёд. Дальше блок погоды просто исчезает;

  • адрес площадки берётся из каталога и никогда не додумывается. Если каталог его не дал, экран говорит об этом и не ставит метку на карте, а агента ответ просит поискать адрес в открытых источниках и сказать человеку, что адрес не из каталога;

  • мультитранспорт Туту считает взрослых, детских и льготных тарифов в поездке нет;

  • ссылка на авиабилет в холодном браузере открывает поиск, а не корзину: корзина заводится только в браузере с живой сессией Туту. Поэтому подпись такой кнопки об этом и говорит;

  • схемы мест в вагоне нет;

  • цена участия в событии складывается в итог только если каталог написал её числом. Текстовую цену вроде "бесплатно для студентов" Заезд показывает как есть и в сумму не берёт;

  • в режиме replay ссылки на оплату собраны из записи и, скорее всего, протухли. Экран и ответ агенту об этом предупреждают;

  • у каталога за раз запрашивается восемь событий: на большем числе он перестаёт отдавать поток посреди ответа. Замер и обоснование в журнале решений.

Измерено

Метрика

Значение

Инструментов у Tutu MCP

16

Манифест Tutu MCP

102 143 символа, около 25,5 тыс. токенов

Инструментов у шлюза Заезда

3

Манифест шлюза Заезда

10 181 символ, около 2,5 тыс. токенов

Живая сборка поездки, холодная

12,1 с

Живая сборка поездки, повторная

0,2 с

Сборка поездки из записи

36 мс

Внешних источников

6

Исполняемых сценариев

217

Юнит-тестов

336

Проверено в агентах

Claude Code, Qwen Code

Карта репозитория

specs/      спецификация продукта, она же источник правды
features/   исполняемые сценарии на Gherkin и шаги к ним
tests/      юнит-тесты чистого слоя
src/        код продукта по слоям
fixtures/   записанные ответы источников
scripts/    record.ts и вспомогательные утилиты
docs/       руководство пользователя, архитектура, журнал решений

Лицензия

MIT, см. LICENSE.

Related MCP Connectors

Related MCP Servers