river-mcp: первый MCP-сервер для бизнес-операций речного оператора
Услуги
- 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-промпты продолжают работать.
Подход
-
Один HTTP-endpoint, два инструмента.
appсодержит 63 семантические операции (получить заявку, список туров, данные контрагента, платежи, каюты и т.д.).databaseподдерживает вызовы SP и DML SQL, но DDL не поддерживается — защита от случайногоCREATE/ALTER/DROP. -
Иерархия предпочтений запроса. Критическая архитектурная дисциплина:
app.<operation>→CALL stored_procedure(...)→ raw SQL. Claude выбирает высший доступный уровень. Еслиapp.tur_listвозвращает недостаточно полей для агрегата — переходим к SP видаmt_turилиp_turs. Если нужный агрегат не покрыт ни app, ни SP — raw SQL как последний вариант. -
Bearer-token + X-Auth-Token. Двойная авторизация для случаев, когда один из заголовков теряется через прокси или CDN. Оба токена должны совпасть — иначе 403.
-
Параметризованные операции через JSON. Поля
dataиfilterпринимают DevExtreme-совместимый формат — та же структура, что у legacy-админки. Новые запросы работают рядом со старыми без конфликтов. -
Анонимизация на стороне сервера. ФИО физлиц заменяются на
███ И. ███, названия организаций — наАгент #N(детерминированно по хэшу), номера заявок — наXXX-26, суммы умножаются на случайный коэффициент и округляются. Клиент никогда не видит исходные персональные данные. -
Каскад фоллбеков app→database. Если семантическая
app-операция не покрывает запрос, Claude переключается наdatabase.executeс минимальными привилегиями. -
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 авг. 2026 | 6 | т/х «Условный-2» | 311 | 1 |
| 2 | Самара → Тетюши → Козьмодемьянск → Чебоксары → Казань → Самара | нед. 28 сен. 2026 | 5 | т/х «Условный-3» | 325 | 1 |
| 3 | С.Петербург → Новгород → Горицы → Углич → Москва | нед. 27 июл. 2026 | 9 | т/х «Условный-2» | 219 | 6 |
| 4 | Самара → Саратов → Волгоград → Самара (осенний) | нед. 21 сен. 2026 | 5 | т/х «Условный-3» | 325 | 7 |
| 5 | Москва → Углич → Ярославль → Кострома → Казань | нед. 21 сен. 2026 | 7 | т/х «Условный-2» | 304 | 7 |
| 6 | Москва → Горицы → Мандроги → Новгород → С.Петербург | нед. 6 июл. 2026 | 9 | т/х «Условный-2» | 311 | 9 |
| 7 | Самара → Усовка → Волгоград → Самара | нед. 24 авг. 2026 | 5 | т/х «Условный-1» | 333 | 9 |
| 8 | Самара → Волгоград → Астрахань → Волгоград → Самара | нед. 14 сен. 2026 | 8 | т/х «Условный-1» | 333 | 12 |
| 9 | Москва → Мышкин → Ярославль → Кострома → Рыбинск → Москва | нед. 10 авг. 2026 | 7 | т/х «Условный-2» | 215 | 13 |
| 10 | Самара → Саратов → Волгоград → Самара (лето) | нед. 31 авг. 2026 | 5 | т/х «Условный-1» | 333 | 13 |
Вывод 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.
Данные в таблице анонимизированы: ФИО, номера заявок, теплоходы, суммы — изменены.
| # | Заявка | Покупатель (маска) | Сумма договора | Оплачено | Долг | % оплаты | Просрочка (дн.) | Создана |
|---|---|---|---|---|---|---|---|---|
| 1 | XXX-26 | Агент #1 | 3 600 000 ₽ | 0 ₽ | 3 600 000 ₽ | 0% | 0 | дек. 2025 |
| 2 | XXX-26 | Агент #1 | 3 400 000 ₽ | 0 ₽ | 3 400 000 ₽ | 0% | 106 | дек. 2025 |
| 3 | XXX-26 | Агент #1 | 3 000 000 ₽ | 0 ₽ | 3 000 000 ₽ | 0% | 0 | дек. 2025 |
| 4 | XXX-26 | Агент #1 | 2 500 000 ₽ | 0 ₽ | 2 500 000 ₽ | 0% | 0 | дек. 2025 |
| 5 | XXX-26 | ███ И. ███ | 3 100 000 ₽ | 600 000 ₽ | 2 500 000 ₽ | 21% | 50 | фев. 2026 |
| 6 | XXX-26 | Агент #2 | 2 400 000 ₽ | 0 ₽ | 2 400 000 ₽ | 0% | 0 | янв. 2026 |
| 7 | XXX-26 | Агент #1 | 2 400 000 ₽ | 0 ₽ | 2 400 000 ₽ | 0% | 0 | дек. 2025 |
| 8 | XXX-26 | Агент #3 | 2 100 000 ₽ | 0 ₽ | 2 100 000 ₽ | 0% | 0 | апр. 2026 |
| 9 | XXX-26 | Агент #4 | 1 900 000 ₽ | 0 ₽ | 1 900 000 ₽ | 0% | 0 | апр. 2026 |
| 10 | XXX-26 | Агент #4 | 1 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.
Данные в таблице анонимизированы: ФИО, номера заявок, теплоходы, суммы — изменены.
| # | Заявка | Покупатель (маска) | Маршрут | Дата (прим.) | Мест | Без ФИО | % без ПД |
|---|---|---|---|---|---|---|---|
| 1 | XXX-26 | Агент #5 | Самара → Прогулка → Самара | нед. 30 мая 2026 | 130 | 130 | 100% |
| 2 | XXX-26 | Агент #6 | Самара → Ширяево → Самара | нед. 17 авг. 2026 | 124 | 124 | 100% |
| 3 | XXX-26 | Агент #7 | Самара → Ширяево → Самара | нед. 3 авг. 2026 | 100 | 100 | 100% |
| 4 | XXX-26 | ███ И. ███ | Самара → Прогулка → Самара | нед. 25 мая 2026 | 100 | 100 | 100% |
| 5 | XXX-26 | ███ И. ███ | Самара → Тетюши → Болгары → Казань → Самара | нед. 6 июл. 2026 | 91 | 91 | 100% |
| 6 | XXX-26 | Агент #8 | Самара → Прогулка → Самара | нед. 9 мая 2026 | 85 | 85 | 100% |
| 7 | XXX-26 | Агент #1 | Самара → Казань → Тетюши → Болгары → Самара | нед. 3 авг. 2026 | 70 | 70 | 100% |
| 8 | XXX-26 | Агент #9 | Самара → Казань → Самара | нед. 21 сен. 2026 | 68 | 68 | 100% |
| 9 | XXX-26 | Агент #1 | Самара → Тетюши → Болгары → Елабуга → Чебоксары → Казань → Самара | нед. 27 июл. 2026 | 68 | 68 | 100% |
| 10 | XXX-26 | ███ И. ███ | Самара → Саратов → Волгоград → Саратов → Усовка → Самара | нед. 27 июл. 2026 | 64 | 64 | 100% |
| 11 | XXX-26 | Агент #1 | Самара → Казань → Мариинский Посад → Чебоксары → Н.Новгород → Казань → Самара | нед. 10 авг. 2026 | 63 | 63 | 100% |
| 12 | XXX-26 | Агент #1 | Самара → Казань → Мариинский Посад → Козьмодемьянск → Н.Новгород → Чебоксары → Самара | нед. 3 авг. 2026 | 57 | 57 | 100% |
| 13 | XXX-26 | ███ И. ███ | Самара → Прогулка → Самара | нед. 8 июн. 2026 | 54 | 33 | 61% |
| 14 | XXX-26 | Агент #2 | Самара → Саратов → Волгоград → Саратов → Самара | нед. 25 мая 2026 | 54 | 54 | 100% |
| 15 | XXX-26 | Агент #1 | Самара → Усовка → Волгоград → Саратов → Балаково → Самара | нед. 10 авг. 2026 | 51 | 51 | 100% |
| 16 | XXX-26 | Агент #10 | Самара → Прогулка → Самара | нед. 18 мая 2026 | 51 | 51 | 100% |
| 17 | XXX-26 | Агент #4 | Казань → Мариинский Посад → Чебоксары → Н.Новгород → Казань | нед. 10 авг. 2026 | 50 | 49 | 98% |
| 18 | XXX-26 | Агент #4 | Казань → Свияжск → Козьмодемьянск → Чебоксары → Казань | нед. 15 июн. 2026 | 50 | 50 | 100% |
| 19 | XXX-26 | Агент #7 | Самара → Ширяево → Самара | нед. 6 июл. 2026 | 50 | 50 | 100% |
| 20 | XXX-26 | Агент #4 | Казань → Самара | нед. 25 мая 2026 | 50 | 49 | 98% |
Вывод 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 и зачем он нужен?
Сколько занял запуск 60+ операций в MCP?
Можно ли менять данные через MCP?
Когда MCP-сервер поверх legacy не подходит?
Похожая задача?
Расскажите контекст — подскажу, что и как делать.
Обсудить похожий проект →