Skip to main content
Glama
README.md
# RZD API for Bun

Типизированный асинхронный клиент на Bun/TypeScript и MCP-сервер для
неофициального API [`ticket.rzd.ru`](https://ticket.rzd.ru). Проект не связан с
ОАО «РЖД»; внутренние endpoint и схемы ответов могут меняться без предупреждения.

## Возможности

- поиск прямых поездов в одну сторону и туда-обратно;
- поиск станций и разрешение названий в коды;
- календарь поездов и минимальные цены;
- вагоны, места, схемы, изображения и станции маршрута;
- поиск полностью свободного купе по диапазону дат;
- MCP через STDIO и Streamable HTTP;
- retries, таймауты и LRU-кэш станций.

## Установка

Требуется Bun 1.2 или новее.

```sh
bun install
```

## TypeScript API

```ts
import { RzdClient } from "rzd-api";

const client = new RzdClient();
try {
  const routes = await client.searchTickets(
    "Москва",
    "Санкт-Петербург",
    "2026-09-01",
    { adults: 1 },
  );
  console.log(routes);
} finally {
  client.close();
}
```

Основные методы: `searchTickets`, `findStations`, `resolveStationCode`,
`getCarriages`, `getTrainAvailability`, `getMinimalPrices`, `getCarScheme`,
`getCarImages`, `getRouteStations`, `searchFullCompartments`.

## Полное купе

`searchFullCompartments` и MCP-инструмент `search_full_compartments` перебирают
диапазон дат (не более 31 дня) и разделяют два разных ответа:

- `confirmed` — API вернул нужные места внутри одного купе (`FreePlacesByCompartments`),
  указаны номер вагона, номер купе и сами места. Один физический вагон приходит
  несколькими записями — нижние и верхние полки одного купе тарифицируются
  отдельно, — поэтому записи вагона сначала сливаются по номеру, иначе купе из
  четырёх мест выглядит как два раза по два;
- `candidates` — свободных мест в вагоне достаточно, но одно купе не подтверждено:
  `places_not_in_one_compartment` (места в разных купе) или
  `compartment_layout_missing` (в ответе нет разбивки по купе).

Суммарное число свободных мест никогда не переводит вагон в `confirmed`. Инвариант
закреплён тестами на фикстурах в `tests/fixtures/`, поэтому изменение парсера не может
незаметно вернуть более смелую формулировку.

Номера мест возвращаются вместе с их маркерами: `36Ж` — место в женском купе,
`6С` — в смешанном. Купе определяет число, маркер сохраняется, потому что именно
его пассажир увидит в билете.

Поле `checkedAt` содержит момент проверки по московскому времени: наличие мест
устаревает за минуты. Даты, которые не удалось проверить, попадают в `errors`,
а не молча превращаются в «мест нет».

Готовая инструкция для ассистента лежит в `skills/find-full-compartment/`.

## Схема вагона

`getCarScheme` возвращает `imageUrls` — абсолютные ссылки на чертёж вагона
(SVG). MCP-инструмент `get_car_scheme` с `include_image: true` вкладывает сам
чертёж в ответ; по умолчанию выключено.

Чертёж отдаётся в PNG, а не в SVG: модели принимают png, jpeg, gif и webp, а
SVG клиент может только сохранить в файл. Растеризацией занимается
`@resvg/resvg-wasm`; шрифт для номеров мест (сабсет DejaVu Sans, 14 КБ) зашит в
`src/scheme-font.ts`, иначе resvg не нарисует текст. Если растеризатор не
поднялся, возвращается исходный SVG.

Сам чертёж — шаблон: все полки почти белые, номера на них тоже белые, потому что
сайт перекрашивает места под статус. Поэтому `free_places` заливаются синим, а
`selected_places` красным — те же два цвета, что в легенде ticket.rzd.ru.
Номера на незакрашенных полках перекрашиваются в тёмный, на закрашенных
остаются белыми. Без этого картинка нечитаема.

Рисуется весь вагон целиком, около 60 КБ и 300 мс. Кадрирование до одного купе
не делается намеренно: resvg режет уже отрисованное, поэтому зум в купе заставил
бы его сначала нарисовать вагон в 21000 пикселей шириной — это 17 секунд.

Ссылки строятся от `schemeImageBaseUrl` (`RZD_SCHEME_IMAGE_BASE_URL`,
по умолчанию публичный `ticket.rzd.ru`) и **никогда** от `RZD_BASE_URL`: иначе
адрес приватного proxy попадёт в каждый ответ ассистента. Байты, наоборот,
запрашиваются через `RZD_BASE_URL`, чтобы работать из закрытой сети. Инвариант
закреплён тестом в `tests/car-scheme.test.ts`.

## MCP

Локальный STDIO:

```sh
bun run mcp
```

Streamable HTTP на loopback:

```sh
bun run mcp:http
curl http://127.0.0.1:8000/health
```

При публикации на non-loopback адресе нужен Bearer-токен длиной не менее 32
символов:

```sh
MCP_TRANSPORT=streamable-http \
MCP_HOST=0.0.0.0 \
MCP_AUTH_TOKEN="replace-with-a-random-token-at-least-32-characters" \
bun run src/mcp.ts
```

Endpoint: `http://localhost:8000/mcp`. Переменные окружения:
`MCP_PORT`, `MCP_RATE_LIMIT_PER_MINUTE`, `MCP_ALLOWED_HOSTS`.

Подключение к Codex:

```sh
codex mcp add rzd -- bun run /absolute/path/to/rzd-api/src/mcp.ts
```

## Docker

```sh
export MCP_AUTH_TOKEN="replace-with-a-random-token-at-least-32-characters"
docker compose up -d
```

## Vercel

Проект содержит Vercel Functions без web-фреймворка:

- `https://<project>.vercel.app/` — страница с краткой документацией;
- `https://<project>.vercel.app/mcp` — публичный Streamable HTTP MCP;
- `https://<project>.vercel.app/health` — healthcheck.

Страница собирается в `src/landing.ts` из того же `registerMcpTools`, что
регистрирует инструменты в MCP: новый инструмент без русского описания роняет
сборку страницы, поэтому таблица не может разойтись с сервером.

Vercel entrypoint использует Elysia, официальный `mcp-handler` и Bun Runtime.
Маршруты принадлежат самому приложению; Vercel rewrites не используются.

Endpoint РЖД можно переопределить переменными окружения `RZD_BASE_URL` и
`RZD_B2B_BASE_URL`. Значения не должны храниться в репозитории.

Под serverless клиент отказывает быстро: при выставленной `VERCEL` таймаут
становится 8 секунд, повтор один. Иначе зависший upstream съедает всё время
функции, платформа обрывает вызов, и клиент получает не ошибку, а мёртвое
соединение — по нему невозможно понять, что случилось. Переопределяется
`RZD_TIMEOUT_MS` и `RZD_RETRY_TOTAL`.

```sh
vercel deploy
```

## Ограничение нагрузки

Публичный `/mcp` открыт без авторизации, поэтому лимит стоит на эдже Vercel, а не
в коде: serverless-инстанс держит счётчики в памяти, масштабируется горизонтально
и теряет их на каждом холодном старте, так что лимит в процессе — это «N на
инстанс». Эдж к тому же отбивает запрос до вызова функции.

```sh
./scripts/firewall-rate-limit.sh          # применить: 60 запросов в минуту с IP
./scripts/firewall-rate-limit.sh check    # показать живое правило
```

Скрипт идемпотентен: существующее правило редактируется на месте, отсутствующее
создаётся, черновик публикуется. Значения переопределяются переменными
`RATE_LIMIT_REQUESTS`, `RATE_LIMIT_WINDOW`, `RATE_LIMIT_PATH`, `RULE_NAME`.

Ключ лимита — IP клиента. Vercel сам перезаписывает `x-forwarded-for` и не
пропускает внешние значения, поэтому подделать адрес нельзя.

Учтите, что лимит считает входящие запросы, а не исходящие: один вызов
`search_full_compartments` за месяц — это десятки обращений к API РЖД. Сам вызов
ограничен с трёх сторон: диапазон не длиннее 31 дня, поезд не открывается, если
ни одна его купейная группа не дотягивает до нужного числа мест (группа не может
содержать меньше мест, чем любой её вагон, поэтому пропуска подтверждённого купе
не будет), и весь обход ограничен бюджетом `maxRequests` — по умолчанию 150.

К первому подтверждённому купе прикладывается чертёж вагона с залитыми синим
местами — и картинкой в ответе, и ссылкой `image.url` внутри JSON. Ссылка нужна
потому, что не всякий клиент показывает image-блоки: ChatGPT их не видит и,
оставшись без изображения, иллюстрирует ответ фотографиями чужих вагонов из
интернета. Отключается `include_image: false`.

Ответ инструмента несёт чертёж тремя способами, потому что клиенты различаются
в том, что показывают: сама картинка блоком `image` с аннотацией
`audience: ["user"]`, штатный `resource_link` со ссылкой на неё и та же ссылка
полем `image.url` первым в JSON — последним оно быть не может, длинный ответ
клиент обрезает с хвоста.

Ссылка ведёт на собственный endpoint:

```
GET /scheme/552/PcFirstStorey.png?free=33,34,35,36
```

Он рисует ту же схему и кэшируется на сутки. Специально узкий — числовой номер
схемы, известная раскладка, не больше 36 мест, — чтобы не превратиться в
универсальный прокси. Адрес сервера задаётся `RZD_PUBLIC_BASE_URL`.

Диапазон, начинающийся в прошлом, не отвергается, а подрезается сегодняшним днём:
«найди купе на август» приходит целым месяцем и в середине августа, и отказывать
из-за прошедших дат бессмысленно. Фактическое начало видно в `dateFrom` ответа.

Обход ограничен и по времени — `maxSeconds`, по умолчанию 9 секунд. Serverless-
вызов всё равно обрывается платформой через несколько секунд, а оборванный вызов
не сообщает клиенту ничего и выглядит как недоступный сервис. Лучше вернуть
найденное и назвать даты, до которых не дошли.

Обход останавливается, как только набрано `maxResults` подтверждённых купе — по
умолчанию три ближайших варианта, а не весь месяц. Даты, до которых он не дошёл,
возвращаются в `unchecked`, а не выдаются за пустые: с них можно продолжить
поиск. Что не поместилось в ответ, посчитано в `omitted`, потраченные запросы —
в `requests`.

## Логи

Каждый вызов инструмента и каждый запрос наружу пишутся строкой JSON в stdout —
это то, что собирает и показывает Vercel:

```json
{"at":"2026-08-06T11:25:35.095Z","upstream":"suggests","status":200,"ms":504,"attempt":0}
{"at":"2026-08-06T11:25:37.421Z","tool":"search_full_compartments","ms":2331,"ok":true,"blocks":["text","image"]}
```

По `upstream` видно, какие endpoint РЖД дёргались и сколько отвечали, по `tool` —
дошёл ли клиент до инструмента вообще. Без этого зависший upstream и клиент,
который инструмент не вызывал, выглядят снаружи одинаково.

Адрес из `RZD_BASE_URL` в лог не попадает: пишется только путь под ним. Лог —
типовое место, куда утекают секреты, поэтому на это есть тест.

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

```sh
bun run check
```

## Безопасность

Проверка TLS-сертификата включена: `ticket.rzd.ru` предъявляет публично
доверенный сертификат. Отключить её можно только явно, переменной
`RZD_INSECURE_TLS=1` — это нужно, если запрос идёт через собственный proxy с
самоподписанным сертификатом. Не передавайте клиенту секреты или учётные данные.

## Лицензия

MIT