Как мы написали первый MCP-сервер для бизнес-процессов поверх legacy
- mcp
- claude-code
- legacy
- ai-tools
- case-study
Менеджер открывает чат и спрашивает: «Покажи круизы на второе полугодие 2026, которые хуже всего продаются». Через 4 секунды он видит таблицу: маршрут, дата, дней в продаже, количество активных заявок. Без SQL. Без обращения в IT. Без Excel-выгрузки, которую ждут сутки. Это работает поверх восьмилетней legacy-системы на PHP и MariaDB — мы просто обернули её в MCP-сервер. В этой статье разберём, что такое MCP, почему мы выбрали именно этот подход, как построили архитектуру и какие подводные камни обошли.
Что такое MCP за 60 секунд
Model Context Protocol (MCP) — стандартизированный протокол для подключения инструментов и данных к языковым моделям. Если TCP/IP связал компьютеры в сеть, то MCP связывает LLM с вашими системами. Полезная аналогия: MCP — это USB-C для AI-агентов. Один стандарт, любой источник данных.
Протокол поддерживает три транспорта:
- stdio — локальный процесс, запуск через CLI
- SSE (Server-Sent Events) — HTTP-стриминг, классический веб
- Streamable HTTP — persistent connection без оверхеда на повторный handshake
MCP-сервер экспонирует три типа примитивов:
- Tools — что делать (вызовы функций, запросы, мутации)
- Resources — что читать (файлы, БД, документы)
- Prompts — шаблоны запросов
Стандарт разработан Anthropic и принят в 2025 году. Сегодня MCP поддерживают Claude Code, Claude Desktop, ChatGPT, Cursor, Codex и другие LLM-runtime. Это уже де-факто стандарт индустрии, а не vendor lock-in.
Зачем нам понадобилось обернуть legacy в MCP
Контекст задачи. Клиент — один из крупнейших операторов речных круизов в РФ. PHP 5.4/7.3 + MariaDB 10.1, восемь лет данных, 84 хранимых процедуры, десятки тысяч туристических мест. Система работает в проде — её нельзя остановить и нельзя переписать за месяц.
Проблема не в данных. Данные есть, они актуальны. Проблема — в доступе. Аналитический вопрос («какие агентские заявки не оплачены более чем наполовину?») превращался в задачу IT: написать SQL, согласовать доступ, подготовить CSV-выгрузку, объяснить формат. Один цикл — от 1 дня до недели. IT тратил 2-4 часа в день на подобные ad-hoc запросы.
Альтернативы, которые мы рассматривали:
| Подход | Плюсы | Минусы |
|---|---|---|
| BI-системы (PowerBI, Metabase) | Красивые дашборды | Отдельная инфра, ETL-пайплайн, обучение менеджеров, жёсткий набор отчётов |
| Embedded analytics в legacy-PHP | Не надо BI-инфры | Переписывание фронта, только предзаписанные запросы, нет гибкости |
| Прямой SQL-доступ для менеджеров | Максимальная гибкость | Неприемлемый риск: один случайный UPDATE — и данные повреждены |
| MCP поверх legacy | Нулевые затраты на миграцию | Требует разработки сервера |
Почему MCP победил:
- Zero migration cost — legacy остаётся как есть, данные никуда не переезжают
- Zero retraining — менеджер пишет на русском, не учит SQL и не меняет интерфейс
- Instant domain language — 60+ операций уже закодированы в бизнес-логике, MCP просто их экспонирует
- Изолированный слой — DDL (CREATE/ALTER/DROP) технически заблокирован, случайная порча данных исключена
Архитектура: один файл, два инструмента
Вся реализация — один PHP-файл ajax_mcp.php, около 1500 строк. Никакой новой инфраструктуры. Никаких дополнительных серверов.
Два MCP-инструмента:
app — 63 семантические операции с известным контрактом: tur_list, tur_get, schet_get_full, schet_get_payments, kontragent_list, calc_price, payments, ships, excursions и так далее. Каждая операция — одна функция с типизированными параметрами. Только чтение бизнес-сущностей.
database — более низкий уровень: select, execute (DML и вызовы SP), metadata, view_definition, version. DDL не поддерживается — это жёсткое ограничение в роутере, не соглашение о намерениях.
Роутер работает так:
// Псевдокод: ajax_mcp.php router
function handle_request($body) {
$auth = check_bearer($body['headers']) || check_x_auth($body['headers']);
if (!$auth) return error(401);
$tool = $body['tool'];
if ($tool === 'app') {
return route_app($body['operation'], $body['params']);
}
if ($tool === 'database') {
return route_database($body['operation'], $body['query']);
}
// backwards-compat: старые имена операций из ранних session-промптов
if (in_array($tool, ['kontragent', 'schet', 'tur'])) {
return route_app($body['tool'] . '_' . $body['operation'], $body['params']);
}
return error(404);
}
Иерархия предпочтений запросов — критическая архитектурная дисциплина:
app.<operation>— семантическая операция с известным контрактом (предпочтительно)CALL stored_procedure(...)— бизнес-логика, закреплённая в БД (84 SP)- Raw SQL через
database.execute— для агрегатов, которых нет ни в app, ни в SP
Claude инструктируется system-промптом придерживаться этого порядка. На практике большинство аналитических запросов — агрегаты, которых нет в готовых операциях — попадают на третий уровень. Именно так работают все три примера ниже.
Дополнительные детали:
- Bearer-token + X-Auth-Token — двойная авторизация (подробнее в разделе «Подводные камни»)
- Streamable HTTP transport — persistent connection, нет latency на handshake при цепочках запросов
- Backwards-compat алиасы — старые имена операций маппируются на новые обработчики, исторические session-промпты продолжают работать без изменений
- DevExtreme-совместимый JSON — поля
data/filter/sortпринимают тот же формат, что и legacy-админка
5 примеров реальных бизнес-запросов
Пример 1 — Топ-10 круизов на 2026 с наименьшими продажами
Вопрос менеджера: «Покажи круизы на второе полугодие 2026, которые хуже всего продаются — реальные многодневные маршруты, не прогулки».
Как Claude решает задачу: сначала пробует app.tur_list — операция возвращает список туров, но поля заполняемости нет. Среди 84 SP не находится специализированной процедуры для «топ-N туров по числу заявок». Переходит к 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: позиции 1 и 2 — маршруты созданы 311 и 325 дней назад, но имеют всего по 1 заявке. Это не новинки, которым нужно время. Это маршруты с многомесячным периодом продажи и катастрофически низкой конверсией. Рекомендация: подключить акционное предложение или перераспределить маркетинговый бюджет с позиций 6-10 (9-13 заявок, хорошая динамика).
Пример 2 — Топ-10 заявок 2026 с просрочкой оплаты более 50%
Вопрос менеджера: «Покажи заявки на туры этого года, где оплачено меньше половины суммы — по убыванию долга».
Как Claude решает задачу: пробует app.schet_list — нет параметра сортировки по неоплаченной сумме. Среди SP не находится sp_overdue_payments. Переходит к 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 дней. Весь этот анализ появился за 4 секунды без единого SQL-запроса от менеджера.
Пример 3 — Топ-20 заявок 2026: более 50% мест без персональных данных
Вопрос менеджера: «Покажи групповые заявки на этот год, где больше половины мест без ФИО туристов».
Как Claude решает задачу: пробует app.schet_get_tourists — работает по одной заявке, N+1 на список неприемлем. SP incompleteSchets существует, но не принимает read-only параметры для фильтрации по году. Переходит к 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 мест без ФИО: один крупный корпоративный клиент требует персонального контроля.
Пример 4 — Агенты с долей возвратов выше 5% за сезон
Вопрос менеджера: «Какие агенты дали больше всего возвратов в этом сезоне?»
Логика запроса: Claude строит SQL JOIN aa_schet ↔ aa_kontragent ↔ таблицы возвратов, GROUP BY id_kontragent (агент), HAVING return_rate > 0.05. Колонки результата: Агент #N, всего заявок, заявок с возвратом, % возврата, суммарная сумма возвратов.
Этот запрос — хороший пример того, почему важен уровень database.execute: аналитика по аномалиям поведения агентов не предусмотрена ни в одной из 63 операций app, и ни одна из 84 SP не считает return_rate на лету. Зато исходные данные есть в БД — и Claude добирается до них напрямую.
Пример 5 — Туры, которые сдвинули дату старта за последний месяц
Вопрос менеджера: «Какие туры поменяли дату отправления в последние 30 дней?»
Логика запроса: Claude обращается к таблице changelog — SELECT id_tur, datefrom, prev_datefrom FROM aa_tur_changelog WHERE field = 'datefrom' AND change_date > NOW() - INTERVAL 30 DAY. Колонки: id_tur (маска 12***), маршрут, старая дата → новая дата, причина изменения (если заполнена).
Такой запрос невозможен через app.tur_list (нет истории изменений) и нет SP для changelog. Только raw SQL — и ответ приходит за 3-4 секунды вместо ручного поиска по журналу.
7 подводных камней
За 2.5 месяца эксплуатации мы набрали коллекцию неочевидных проблем. Вот семь из них.
1. Bearer-токен теряется через CDN
Первая боевая проверка показала: некоторые прокси и CDN-узлы срезают заголовок Authorization. Решение — дублирующий заголовок X-Auth-Token. MCP-клиент отправляет оба, сервер принимает любой. Если за прокси — один заголовок спасает там, где другой теряется.
2. Streamable HTTP timeout на длинных SQL
Сложный агрегат с несколькими JOIN’ами на большой таблице может занять 15-30 секунд. Промежуточные прокси закрывают idle-соединение раньше. Решение: chunked response с keep-alive ping каждые 5 секунд. Прокси видит активность и не рвёт соединение.
3. OPcache не сбрасывался при rsync-деплое
PHP-файл деплоится через rsync на beget-хостинг с FastCGI. OPcache видит старую версию байт-кода — изменения не применяются. Решение: добавили вызов opcache_reset() в первый GET-запрос после деплоя. Просто и надёжно.
4. DevExtreme JSON в payload
Legacy-админка использует DevExtreme-совместимый формат для data/filter/sort. Мы оставили этот формат и в MCP, чтобы не делать два разных backend’а. Теперь один PHP-файл обслуживает и старую админку, и новый MCP-слой. Двойное использование одного backend’а без конфликтов.
5. Анонимизация ПД — на стороне сервера, не клиента
Первый инстинкт — анонимизировать данные на стороне клиента (в промпте или постобработке ответа). Это неверно: между сервером и клиентом данные прошли бы в исходном виде. Правильно — response-mapper на каждую операцию в PHP. ФИО заменяются на ███ И. ███, организации — на Агент #N, номера заявок — на XXX-26 ещё до выхода данных с сервера. Клиент никогда не видит исходные персональные данные.
6. SQL-ошибки утекают в Claude как plaintext
По умолчанию MariaDB возвращает ошибку в виде строки: You have an error in your SQL syntax near '...' at line 3. Claude получает это как текст и не всегда может корректно переформулировать запрос. Решение: оборачиваем все ошибки в структурированный объект { code, message, hint }. Теперь Claude парсит ошибку, понимает причину и либо исправляет запрос, либо объясняет менеджеру понятным языком.
7. Прод-данные в dev
Разработчик тестирует новую операцию — и случайно использует прод-конфиг с реальными данными. Решение: MCP-сервер читает .env с переменной MCP_ENV=prod|dev. В .mcp.json для dev и prod прописаны разные URL с разными credentials. Переключение явное, случайная работа с продом исключена.
Что получили
После 2.5 месяцев в проде картина такая:
- 63 операции доступны через диалог на русском языке — без SQL, без Excel, без обращений в IT
- ~50 запросов в день — менеджеры самостоятельно получают аналитику в моменте
- Время ответа p95 ~3-4 секунды — от вопроса на русском до таблицы с данными
- 0 инцидентов с потерей данных за 2.5 месяца эксплуатации
- ~3 часа IT/день освобождены от ad-hoc выгрузок и аналитических запросов
- Дисциплина данных: в первом же сеансе выявлен один корпоративный клиент с 5 заявками, суммарным долгом ~15 млн ₽ и просрочкой до 177 дней — до MCP эта картина собиралась вручную раз в месяц
Когда вашему legacy нужен MCP
Не каждой системе нужен MCP-слой. Вот четыре признака хорошего кандидата:
- Есть SQL-доступ к данным — реляционная СУБД с читаемой схемой (MySQL, PostgreSQL, MSSQL, Oracle)
- Более 5 повторяющихся аналитических запросов в неделю — и каждый раз это задача для IT
- IT-команда тратит более 2 часов в день на ad-hoc — это прямые потери времени, которые можно автоматизировать
- Данные не уходят за периметр — внутренняя система, данные остаются на ваших серверах (анонимизация опциональна)
Если три пункта из четырёх совпадают — ваша система кандидат на MCP-обёртку.
Если ваш legacy подходит под этот чек-лист — мы оборачиваем его в MCP-сервер за 2-3 недели. Менеджер получает аналитику в чате на русском языке, IT освобождается от ad-hoc отчётов. Написать нам