Как мы написали первый 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-агентов. Один стандарт, любой источник данных.

Протокол поддерживает три транспорта:

MCP-сервер экспонирует три типа примитивов:

Стандарт разработан 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 победил:

  1. Zero migration cost — legacy остаётся как есть, данные никуда не переезжают
  2. Zero retraining — менеджер пишет на русском, не учит SQL и не меняет интерфейс
  3. Instant domain language — 60+ операций уже закодированы в бизнес-логике, MCP просто их экспонирует
  4. Изолированный слой — 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);
}

Иерархия предпочтений запросов — критическая архитектурная дисциплина:

  1. app.<operation> — семантическая операция с известным контрактом (предпочтительно)
  2. CALL stored_procedure(...) — бизнес-логика, закреплённая в БД (84 SP)
  3. Raw SQL через database.execute — для агрегатов, которых нет ни в app, ни в SP

Claude инструктируется system-промптом придерживаться этого порядка. На практике большинство аналитических запросов — агрегаты, которых нет в готовых операциях — попадают на третий уровень. Именно так работают все три примера ниже.

Дополнительные детали:

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 авг. 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: позиции 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.

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

#ЗаявкаПокупатель (маска)Сумма договораОплаченоДолг% оплатыПросрочка (дн.)Создана
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 дней. Весь этот анализ появился за 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.

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

#ЗаявкаПокупатель (маска)МаршрутДата (прим.)МестБез ФИО% без ПД
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 мест без ФИО: один крупный корпоративный клиент требует персонального контроля.


Пример 4 — Агенты с долей возвратов выше 5% за сезон

Вопрос менеджера: «Какие агенты дали больше всего возвратов в этом сезоне?»

Логика запроса: Claude строит SQL JOIN aa_schetaa_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 месяцев в проде картина такая:

Когда вашему legacy нужен MCP

Не каждой системе нужен MCP-слой. Вот четыре признака хорошего кандидата:

  1. Есть SQL-доступ к данным — реляционная СУБД с читаемой схемой (MySQL, PostgreSQL, MSSQL, Oracle)
  2. Более 5 повторяющихся аналитических запросов в неделю — и каждый раз это задача для IT
  3. IT-команда тратит более 2 часов в день на ad-hoc — это прямые потери времени, которые можно автоматизировать
  4. Данные не уходят за периметр — внутренняя система, данные остаются на ваших серверах (анонимизация опциональна)

Если три пункта из четырёх совпадают — ваша система кандидат на MCP-обёртку.


Если ваш legacy подходит под этот чек-лист — мы оборачиваем его в MCP-сервер за 2-3 недели. Менеджер получает аналитику в чате на русском языке, IT освобождается от ad-hoc отчётов. Написать нам