river-mcp: первый MCP-сервер для бизнес-операций речного оператора

Крупный оператор речных круизов (анонимно) Один из крупнейших речных операторов РФ. Десятки тысяч заявок в сезон, флот 10+ судов, 8+ лет legacy. Туризм и пассажирские перевозки 2026-02 — наст. время
60+
бизнес-операций в одном MCP
8 лет
legacy-данных доступно через диалог
0
SQL-запросов от менеджеров

Услуги

  • ai-development
  • complex-systems
  • legacy-modernization

Стек

  • MCP HTTP
  • PHP 7.3
  • MariaDB 10.1
  • Bearer-auth
  • Claude Code
  • Streamable HTTP transport

Крупный оператор речных круизов — туризм и пассажирские перевозки. river-mcp: первый MCP-сервер поверх чужого 8-летнего legacy, 60+ бизнес-операций, 0 SQL-запросов от менеджеров. С 2026-02. Аналитический цикл сократился с 1 дня — недели до 4 секунд через диалог на русском.

Менеджер открывает чат и спрашивает: «Покажи топ-10 круизов на 2026 с проблемными продажами». Через 4 секунды получает таблицу: маршрут, дата, дней в продаже, количество активных заявок. Ни одного SQL-запроса, ни одного обращения в IT. Так работает river-mcp — первый MCP-сервер, который мы написали поверх чужого 8-летнего legacy для повседневных бизнес-операций.

Контекст

У оператора — PHP 5.4/7.3 + MariaDB 10.1, восемь лет таблиц aa_schet, aa_order, aa_place, aa_kontragent, aa_tur, aa_import_buh. За это время накопились 84 хранимых процедуры (SP), тысячи заявок и десятки тысяч туристических мест с персональными данными. Система работает в проде — её нельзя остановить, нельзя переписать за месяц.

Проблема не в данных: данные есть, они актуальны. Проблема в доступе. Аналитический вопрос («какие агентские заявки за 2026 не оплачены более чем наполовину?») превращался в задачу IT: написать SQL, согласовать доступ, дать CSV-выгрузку, объяснить формат. Один цикл — от 1 дня до недели.

Мы рассматривали альтернативы: BI-системы (PowerBI, Metabase) требуют отдельной инфраструктуры и ETL-пайплайна; embedded-аналитика в legacy-PHP означала бы переписывание фронта; прямой SQL-доступ для менеджеров — неприемлемый риск (один ошибочный UPDATE — и данные повреждены). Победил MCP: нулевые затраты на миграцию данных, естественный язык запроса, изолированный read-only слой поверх существующей БД.

Какие вызовы решены

Как дать менеджерам прямой доступ к данным без SQL? Через Model Context Protocol — менеджер разговаривает с Claude на русском, Claude формирует MCP-вызов к серверу, сервер возвращает данные из MariaDB. Никакого SQL в пользовательском интерфейсе.

Как уберечь production от случайного UPDATE/DELETE? Два раздельных инструмента: app (63 семантические операции — только чтение бизнес-сущностей) и database (поддерживает DML и вызовы SP, но DDL полностью заблокирован). Случайный DROP TABLE технически невозможен.

Как масштабировать дисциплину запроса? Введена иерархия предпочтений: сначала app.<operation> (семантическая операция с известным контрактом), затем CALL <stored_procedure>(...) (бизнес-логика, уже закреплённая в БД), и только в крайнем случае — raw SQL для агрегатов, которых нет ни в app, ни в SP. Claude инструктируется system-промптом придерживаться этого порядка.

Как анонимизировать персональные данные для разработчиков? Анонимизация применяется на уровне MCP-сервера до возврата данных. Реальные ФИО, номера заявок, названия судов и суммы никогда не покидают сервер в исходном виде — из клиента видны только маски.

Как масштабировать на 60+ бизнес-операций без N отдельных endpoint’ов? Один HTTP-endpoint принимает JSON с полем operation. Добавление новой операции — одна функция в PHP, без изменения маршрутизации и без деплоя инфраструктуры.

Как обновлять без downtime? PHP-файл деплоится через rsync на beget-хостинг. FastCGI подхватывает изменения без перезапуска — пользователи не замечают обновлений.

Как поддерживать backwards-compat со старыми именами инструментов? Через алиасы: старые имена operations маппируются на новые обработчики внутри роутера. Переход бесшовный — исторические session-промпты продолжают работать.

Подход

  1. Один HTTP-endpoint, два инструмента. app содержит 63 семантические операции (получить заявку, список туров, данные контрагента, платежи, каюты и т.д.). database поддерживает вызовы SP и DML SQL, но DDL не поддерживается — защита от случайного CREATE/ALTER/DROP.

  2. Иерархия предпочтений запроса. Критическая архитектурная дисциплина: app.<operation>CALL stored_procedure(...) → raw SQL. Claude выбирает высший доступный уровень. Если app.tur_list возвращает недостаточно полей для агрегата — переходим к SP вида mt_tur или p_turs. Если нужный агрегат не покрыт ни app, ни SP — raw SQL как последний вариант.

  3. Bearer-token + X-Auth-Token. Двойная авторизация для случаев, когда один из заголовков теряется через прокси или CDN. Оба токена должны совпасть — иначе 403.

  4. Параметризованные операции через JSON. Поля data и filter принимают DevExtreme-совместимый формат — та же структура, что у legacy-админки. Новые запросы работают рядом со старыми без конфликтов.

  5. Анонимизация на стороне сервера. ФИО физлиц заменяются на ███ И. ███, названия организаций — на Агент #N (детерминированно по хэшу), номера заявок — на XXX-26, суммы умножаются на случайный коэффициент и округляются. Клиент никогда не видит исходные персональные данные.

  6. Каскад фоллбеков app→database. Если семантическая app-операция не покрывает запрос, Claude переключается на database.execute с минимальными привилегиями.

  7. Streamable HTTP transport. Persistent connection без latency на повторный handshake. Важно при цепочках запросов — получить список туров, затем детали по каждому найденному.

Примеры бизнес-запросов

Пример 1 — Топ-10 круизов 2026 с наименьшим числом продаж

Вопрос менеджера: «Покажи круизы на второе полугодие 2026, которые хуже всего продаются — реальные многодневные маршруты, не прогулки».

Claude пробует app.tur_list — операция возвращает список туров, но поля заполняемости нет. Среди 84 SP не находится специализированной процедуры для «топ-N туров по числу заявок». Claude переходит к database.execute с SQL JOIN aa_tur + aa_teplohod + подзапрос COUNT по aa_schet, фильтрует visible=1, isdelete=0, annul=0, is_sostav=0, dlit >= 5, туры созданные более 30 дней назад.

Данные в таблице анонимизированы: ФИО, номера заявок, теплоходы, суммы — изменены.

#МаршрутДата отправленияДнейТеплоходДней в продажеАктивных заявок
1Москва → Калязин → Рыбинск → Ярославль → Углич → Москванед. 24 авг. 20266т/х «Условный-2»3111
2Самара → Тетюши → Козьмодемьянск → Чебоксары → Казань → Самаранед. 28 сен. 20265т/х «Условный-3»3251
3С.Петербург → Новгород → Горицы → Углич → Москванед. 27 июл. 20269т/х «Условный-2»2196
4Самара → Саратов → Волгоград → Самара (осенний)нед. 21 сен. 20265т/х «Условный-3»3257
5Москва → Углич → Ярославль → Кострома → Казаньнед. 21 сен. 20267т/х «Условный-2»3047
6Москва → Горицы → Мандроги → Новгород → С.Петербургнед. 6 июл. 20269т/х «Условный-2»3119
7Самара → Усовка → Волгоград → Самаранед. 24 авг. 20265т/х «Условный-1»3339
8Самара → Волгоград → Астрахань → Волгоград → Самаранед. 14 сен. 20268т/х «Условный-1»33312
9Москва → Мышкин → Ярославль → Кострома → Рыбинск → Москванед. 10 авг. 20267т/х «Условный-2»21513
10Самара → Саратов → Волгоград → Самара (лето)нед. 31 авг. 20265т/х «Условный-1»33313

Вывод Claude: первые два маршрута — созданы 311 и 325 дней назад, но имеют всего по 1 заявке. Это не новинки — это маршруты, которые плохо продаются при длительном нахождении в продаже. Рекомендация: подключить акционное предложение или перераспределить маркетинговый бюджет с позиций 6-10 (9-13 заявок, хорошая динамика).


Пример 2 — Заявки 2026 с просрочкой оплаты более 50%

Вопрос менеджера: «Покажи заявки на туры этого года, где оплачено меньше половины суммы — по убыванию долга».

Claude пробует app.schet_list — нет параметра сортировки по неоплаченной сумме. Среди SP не находится sp_overdue_payments или аналога. Claude переходит к database.execute с SQL на aa_schet JOIN aa_tur, фильтр status=1 (рабочая заявка), fullcost > 0, realcost < fullcost * 0.5, JOIN по YEAR(t.datefrom) = 2026.

Данные в таблице анонимизированы: ФИО, номера заявок, теплоходы, суммы — изменены.

#ЗаявкаПокупатель (маска)Сумма договораОплаченоДолг% оплатыПросрочка (дн.)Создана
1XXX-26Агент #13 600 000 ₽0 ₽3 600 000 ₽0%0дек. 2025
2XXX-26Агент #13 400 000 ₽0 ₽3 400 000 ₽0%106дек. 2025
3XXX-26Агент #13 000 000 ₽0 ₽3 000 000 ₽0%0дек. 2025
4XXX-26Агент #12 500 000 ₽0 ₽2 500 000 ₽0%0дек. 2025
5XXX-26███ И. ███3 100 000 ₽600 000 ₽2 500 000 ₽21%50фев. 2026
6XXX-26Агент #22 400 000 ₽0 ₽2 400 000 ₽0%0янв. 2026
7XXX-26Агент #12 400 000 ₽0 ₽2 400 000 ₽0%0дек. 2025
8XXX-26Агент #32 100 000 ₽0 ₽2 100 000 ₽0%0апр. 2026
9XXX-26Агент #41 900 000 ₽0 ₽1 900 000 ₽0%0апр. 2026
10XXX-26Агент #41 600 000 ₽260 000 ₽1 340 000 ₽17%177окт. 2025

Вывод Claude: Агент #1 — один корпоративный заказчик с 5 заявками суммарным долгом ~15 млн ₽. Строка 2: просрочка 106 дней при долге 3.4 млн — требует немедленного звонка. Строка 10: наибольшая задержка в выборке — 177 дней, сумма 1.3 млн. Весь этот анализ появился за 4 секунды без единого SQL-запроса от менеджера.


Пример 3 — Заявки 2026: более 50% мест без персональных данных

Вопрос менеджера: «Покажи групповые заявки на этот год, где больше половины мест без ФИО туристов».

Claude пробует app.schet_get_tourists — работает по одной заявке, N+1 на список неприемлем. SP incompleteSchets существует, но не принимает read-only параметры для фильтрации по году. Claude переходит к database.execute с SQL JOIN aa_schet + aa_tur + aa_order + aa_place, GROUP BY заявка, HAVING empty_fio > total_places * 0.5 AND total_places >= 4, фильтр YEAR(t.datefrom) = 2026.

Данные в таблице анонимизированы: ФИО, номера заявок, теплоходы, суммы — изменены.

#ЗаявкаПокупатель (маска)МаршрутДата (прим.)МестБез ФИО% без ПД
1XXX-26Агент #5Самара → Прогулка → Самаранед. 30 мая 2026130130100%
2XXX-26Агент #6Самара → Ширяево → Самаранед. 17 авг. 2026124124100%
3XXX-26Агент #7Самара → Ширяево → Самаранед. 3 авг. 2026100100100%
4XXX-26███ И. ███Самара → Прогулка → Самаранед. 25 мая 2026100100100%
5XXX-26███ И. ███Самара → Тетюши → Болгары → Казань → Самаранед. 6 июл. 20269191100%
6XXX-26Агент #8Самара → Прогулка → Самаранед. 9 мая 20268585100%
7XXX-26Агент #1Самара → Казань → Тетюши → Болгары → Самаранед. 3 авг. 20267070100%
8XXX-26Агент #9Самара → Казань → Самаранед. 21 сен. 20266868100%
9XXX-26Агент #1Самара → Тетюши → Болгары → Елабуга → Чебоксары → Казань → Самаранед. 27 июл. 20266868100%
10XXX-26███ И. ███Самара → Саратов → Волгоград → Саратов → Усовка → Самаранед. 27 июл. 20266464100%
11XXX-26Агент #1Самара → Казань → Мариинский Посад → Чебоксары → Н.Новгород → Казань → Самаранед. 10 авг. 20266363100%
12XXX-26Агент #1Самара → Казань → Мариинский Посад → Козьмодемьянск → Н.Новгород → Чебоксары → Самаранед. 3 авг. 20265757100%
13XXX-26███ И. ███Самара → Прогулка → Самаранед. 8 июн. 2026543361%
14XXX-26Агент #2Самара → Саратов → Волгоград → Саратов → Самаранед. 25 мая 20265454100%
15XXX-26Агент #1Самара → Усовка → Волгоград → Саратов → Балаково → Самаранед. 10 авг. 20265151100%
16XXX-26Агент #10Самара → Прогулка → Самаранед. 18 мая 20265151100%
17XXX-26Агент #4Казань → Мариинский Посад → Чебоксары → Н.Новгород → Казаньнед. 10 авг. 2026504998%
18XXX-26Агент #4Казань → Свияжск → Козьмодемьянск → Чебоксары → Казаньнед. 15 июн. 20265050100%
19XXX-26Агент #7Самара → Ширяево → Самаранед. 6 июл. 20265050100%
20XXX-26Агент #4Казань → Самаранед. 25 мая 2026504998%

Вывод Claude: строки 1, 4, 6, 16 — рейсы в конце мая 2026, персональные данные не заполнены совсем. Для маршрутов, попадающих под ГИС ЭП «Электронная путёвка», ПД должны быть переданы за 7 дней до отплытия. Агент #1 — 5 заявок, суммарно более 300 мест без ФИО: один крупный корпоративный клиент требует персонального контроля.

Результат

  • 63 операции доступны через диалог на русском языке — без SQL, без Excel, без обращений в IT
  • Время до инсайта: 4 секунды вместо 1-3 дней ожидания выгрузки
  • Audit-trail каждого запроса на уровне MCP-сервера — видно, кто, что и когда спросил
  • Read-only по умолчанию в app-инструменте — случайное изменение данных исключено
  • Менеджеры самостоятельны в аналитике — не зависят от расписания IT-команды

Эффект для бизнеса

  • −2-4 ч/день IT-ресурса на подготовку ad-hoc выгрузок и ответы на аналитические вопросы
  • Время до инсайта: с 1-3 дней до 4 секунд — менеджер получает ответ в моменте, а не в конце дня
  • Дисциплина данных: в первом же сеансе выявлен один корпоративный клиент, держащий 5 заявок с суммарным долгом ~15 млн ₽ и просрочкой до 177 дней — до MCP эта картина собиралась вручную раз в месяц
  • Обоснование инвестиций в Supabase-миграцию: MCP показал, какие аналитические запросы нужны бизнесу — это прямой input в приоритизацию новых SP и схем в современной БД

Что использовали

PHP 7.3 (FastCGI на beget-хостинге) + MariaDB 10.1 — существующая legacy-инфраструктура, ничего не поднималось заново. MCP-сервер реализован как один PHP-файл (ajax_mcp.php) ~1500 строк: роутер операций, параметрический парсер DevExtreme-совместимого JSON, слой анонимизации, двойная авторизация Bearer + X-Auth-Token. Транспорт — Streamable HTTP (persistent connection по спецификации MCP 2025). Подключён в Claude Code через .mcp.json на машине разработчика; для менеджеров — через отдельный Claude Desktop конфиг.

84 существующих SP не переписывались — MCP использует их как есть через CALL. Добавление новой операции занимает 30-60 минут: один обработчик в PHP, обновление .mcp.json с описанием параметров, тест через Claude Code.

Что мы можем сделать у вас

Если у вас есть legacy-система с 5+ повторяющимися аналитическими запросами в неделю — мы заворачиваем её в MCP-сервер за 2-3 недели. Менеджер получает аналитику в чате на русском языке, IT освобождается от ad-hoc отчётов. Подойдёт любая реляционная СУБД с SQL-доступом — MySQL, Postgres, MSSQL, Oracle. Написать нам

Часто задаваемые вопросы

Что такое river-mcp и зачем он нужен?
MCP-сервер поверх 8-летнего legacy на PHP 7.3 + MariaDB 10.1. Выставляет 60+ бизнес-операций (поиск туров, статусы заявок, аналитика продаж) через MCP HTTP с Bearer-auth. Менеджер задаёт вопросы на русском в Claude Code — получает ответ за ~4 сек. 0 SQL-запросов, 0 обращений в IT для рутинной аналитики.
Сколько занял запуск 60+ операций в MCP?
Каркас (Streamable HTTP transport + Bearer-auth + первые 10 операций) — 2 недели. Полный набор из 60+ операций — ещё ~6 недель параллельно с другими задачами. Аналитический цикл «вопрос → ответ» сократился с 1 дня — недели (через Excel и IT) до 4 секунд через диалог.
Можно ли менять данные через MCP?
Можно, но мы сознательно ограничили MCP read-only операциями для безопасности. Любая мутация (отмена брони, изменение цены, отправка email клиенту) требует обращения в полноценный admin-интерфейс. MCP — это analytics layer над legacy, не CRUD. Это снижает риск некорректных мутаций от LLM.
Когда MCP-сервер поверх legacy не подходит?
Если данных мало и команда из 2-3 человек уже привыкла к Excel-выгрузкам — overhead не окупится. Если legacy без чёткой структуры (хаотичная БД, бизнес-логика в триггерах) — сначала нужен рефакторинг. Не подходит при строгом запрете на outbound LLM — нужна локальная Gemma 4 или Qwen 3.6 в роли LLM-клиента.

Похожая задача?

Расскажите контекст — подскажу, что и как делать.

Обсудить похожий проект →