Однажды один из слушателей моего курса задал вопрос, можно ли подключить локальную нейросеть к базе данных? Я, конечно, ответил «да»: ведь в оболочках, с которыми работаю, не раз видел инструменты для такой интеграции. Но видел — не значит настраивал сам. Вопрос меня сильно зацепил, и я решил сам во всём разобраться, пройти весь путь руками, а не просто показать кнопку подключения.
Можно было бы, конечно, быстро поставить PostgreSQL, создать пару пустых таблиц и получить эффектную демонстрацию. Но это неинтересно. Хотелось какого-то реального кейса, поработать, так сказать, с живой базой. При этом подключаться к действующей базе и рисковать данными — это не мой подход, поэтому я взял тестовую копию внутренней системы учёта обучения1 и развернул её на отдельном стенде.
Моя задача была довольно конкретной: задавать обычным языком вопрос вроде «покажи расписание на следующую неделю» и получать таблицу с курсами, датами, количеством слушателей, форматом обучения, преподавателем и аудиторией. В эксперименте я использовал локальную модель Qwen 3.5 9B Q4_K_M. Желание было простое — получить рабочий результат и по дороге понять, какие части этой конструкции за что отвечают.
С базами данных до этого я работал, скорее, на уровне «установить, запустить и подключиться». Когда дело дошло до схемы, связей и запросов, мои знания быстро закончились. Я никогда не был достаточно усидчивым, чтобы спокойно и последовательно изучить SQL во всей его красе. Зато здесь появился практический повод, локальный Qwen стал напарником: предлагал запросы и помогал разбирать структуру, а я проверял результат на тестовой копии и уточнял задачу.
Содержание статьи
Почему модели нельзя давать логин и пароль
Языковая модель сама по себе не подключается к базе и не «видит» таблицы: она генерирует текст. Чтобы получить внешние данные, ей нужен инструмент, который выполнит запрос и вернёт результат в контекст диалога.
Казалось бы, можно дать агенту полный доступ к SQL и поручить ему писать запросы самостоятельно. Но небольшие локальные модели могут ошибаться в названиях полей, условиях и соединениях таблиц. Большая модель — это тоже не гарантия. Если запрос выполнится не так, как ожидалось, можно получить неверный отчёт, раскрыть лишние данные или даже затронуть исходную базу.
Я предпочёл не полагаться только на качество генерации, а ограничить возможности технически. Oracle хранит данные и выполняет SQL. Подготовленные представления определяют, какие поля вообще доступны. Python-сервис публикует только заранее написанные операции. А агент в AI-приложении решает, какой инструмент вызвать по формулировке пользователя.
Архитектура схемы
| Компонент | Его задача |
|---|---|
| Oracle Database | Исходные данные. К ней подключается выделенный пользователь с правами только на чтение. |
| Представления — VIEW | Собирают нужные сведения из нескольких сущностей и показывают только выбранные поля. |
| Python + MCP | Сервис подключается к базе и предоставляет агенту ограниченный набор инструментов. |
HTTP endpoint /mcp |
Канал, по которому AI-приложение вызывает инструменты; доступ закрыт bearer-токеном. |
| AI-приложение и агент | Получают описание инструментов, выбирают подходящий по запросу пользователя и формируют ответ по возвращённым данным. |
Для меня MCP оказался удобной прослойкой между языковой моделью и обычным кодом. Агенту не нужно знать устройство базы и сочинять SQL с нуля: он видит список доступных инструментов, их параметры и описание. А уже сервер знает, как именно получить расписание или проверить соединение.
Первый шаг: ограниченный пользователь и представления
Для чтения используется отдельный пользователь. Ему разрешается подключение к базе и чтение только специально подготовленных представлений. Это главный предохранитель: доступ к остальным объектам базы ему не предоставляется.
CREATE USER AI_READONLY
IDENTIFIED BY "<СЕКРЕТ>"
ACCOUNT UNLOCK;
GRANT CREATE SESSION TO AI_READONLY;
GRANT READ ON APP_SCHEMA.AI_SCHEDULE_VIEW
TO AI_READONLY;
GRANT READ ON APP_SCHEMA.AI_ENROLLMENTS_VIEW
TO AI_READONLY;
GRANT READ ON APP_SCHEMA.AI_CLASS_DATES_VIEW
TO AI_READONLY;
Дальше пришлось разобраться, как устроены данные и какие связи нужны для расписания. Именно здесь пригодились представления VIEW. Если объяснять на привычном системному администратору языке, то можно привести аналогию с символьной ссылкой, через которую обращаешься к результату, хотя в данном случае сам набор данных не обязательно хранится в виде отдельной копии.
В представления я включал только то, что требовалось для сценария: название курса, даты занятий, имя преподавателя, аудиторию и агрегированные сведения о слушателях. Внутренние имена таблиц и лишние поля не нужны. Сложные связи остаются на стороне базы, а наружу выходит компактная и понятная модель данных.
Основную массу SQL-запросов предлагал Qwen, который пыхтел на моей бедной 5060, разогревая её почти до 70 градусов и выдавая около 50–70 токенов в секунду. Весь процесс описывать не буду: там были обычные пробы и ошибки. В итоге мы получили рабочие представления и проверили, что они возвращают нужные данные.
Самым полезным оказался отдельный слой с фактическими датами занятий. Дата начала курса не всегда помогает найти занятия на следующей неделе: многодневный курс мог начаться месяц назад и при этом продолжаться сейчас. Поэтому календарь занятий я вынес отдельно и использовал при построении недельного расписания.
Поднимаем MCP-сервер на Python
Сам сервер получился обычным Python-приложением. В эксперименте
используются FastMCP, драйвер oracledb,
python-dotenv для загрузки конфигурации
и Uvicorn для HTTP-запуска. Зависимости удобно держать
в виртуальном окружении, чтобы они не смешивались
с остальными пакетами системы.
mkdir -p ~/mcp-oracle-serv
cd ~/mcp-oracle-serv
python3 -m venv mcp
source mcp/bin/activate
cat > requirements.txt <<'EOF'
mcp>=1.28,<2
oracledb>=3.3,<4
python-dotenv>=1.0,<2
uvicorn>=0.34,<1
EOF
pip install --upgrade pip
pip install -r requirements.txt
Параметры подключения вынесены в .env и хранятся отдельно
от кода сервера: имя пользователя, пароль, адрес базы и токен
доступа. Для HTTP-сервера в моём варианте использовался порт
8083, а MCP endpoint доступен по пути /mcp.
ORACLE_USER=AI_READONLY
ORACLE_PASSWORD="<СЕКРЕТ>"
ORACLE_DSN=<DB_HOST>:1521/<PDB_SERVICE>
MCP_HOST=0.0.0.0
MCP_PORT=8083
MCP_BEARER_TOKEN="<MCP_TOKEN>"
MCP_ALLOWED_HOSTS=localhost:8083,127.0.0.1:8083,host.docker.internal:8083
У сервера есть ещё одна важная деталь: проверка Host-заголовка
и защита от DNS rebinding. Это не замена сетевым ограничениям
или аутентификации, а дополнительный рубеж. Если приложение
работает в контейнере, адрес, по которому идёт обращение
к хостовой машине, может отличаться от localhost —
это нужно учитывать при подключении.
Что именно может делать агент
Сам по себе факт, что сервер подключился к базе, ещё не означает,
что агент умеет ею пользоваться. Инструменты нужно объявить
в коде и описать так, чтобы модель понимала, когда и с какими
аргументами их вызывать. В Python это делается декоратором
@mcp.tool(). Например, проверка соединения
может выглядеть так:
@mcp.tool()
def database_status() -> str:
"""Проверить доступность Oracle и контекст подключения."""
sql = """
SELECT
SYS_CONTEXT('USERENV', 'SESSION_USER') AS SESSION_USER,
SYS_CONTEXT('USERENV', 'CON_NAME') AS CONTAINER_NAME,
SYSDATE AS DATABASE_TIME
FROM dual
"""
return query_json(sql)
Функция query_json в этом примере — вспомогательный код
сервера: она открывает соединение, выполняет заранее заданный
запрос и возвращает строки в структурированном виде.
Модель получает доступ не к SQL-консоли, а к результату
работы конкретного инструмента.
В моей конфигурации набор операций выглядел так:
-
database_status— проверить подключение, пользователя сеанса, контейнер Oracle и время базы. -
training summaryиdata_quality_summary— получить сводные количества по типам обучения и проверить полноту связей между записями. -
search_schedule— найти занятия по названию курса, городу, преподавателю, датам и другим допустимым фильтрам. -
get_week_schedule— собрать расписание за период и вернуть его готовой Markdown-таблицей.
Фильтры дат и текстовые параметры передаются в SQL через bind-переменные, а количество возвращаемых строк ограничивается. Это важнее, чем может показаться на первый взгляд: мы не вставляем произвольный текст из запроса пользователя в SQL и не позволяем модели самой сочинить запрос любой сложности.
Дьявол в деталях
На этапе тестирования выяснилось, что запрос «покажи расписание на следующую неделю» нельзя надёжно реализовать простым фильтром по дате начала курса. Если курс стартовал раньше, но занятия продолжаются в выбранный период, он всё равно должен попасть в отчёт.
Поэтому инструмент сначала выбирает занятия, у которых есть фактические даты внутри заданного интервала, и только потом добавляет информацию о курсе и считает слушателей. Для этого используются три сценария:
- Без дат — следующая рабочая неделя с понедельника по пятницу, рассчитанная относительно даты Oracle.
- Одна дата — семидневный период, включающий указанную дату.
- Две даты — интервал с включением обеих границ; максимальная длина ограничена 366 днями.
При подсчёте учитываются уникальные слушатели, отдельно очное и дистанционное обучение. А вот количество дней и даты начала и окончания описывают весь курс, а не только дни внутри выбранной недели. Это пришлось явно проговорить в описании инструмента, иначе модель могла бы трактовать цифры не так, как ожидает пользователь.
Закрываем endpoint и подключаем клиента
MCP-сервер запускается через Uvicorn и публикует Streamable HTTP
endpoint /mcp. Для проверки доступности процесса
предусмотрен отдельный маршрут /health, который
не обращается к содержимому базы. Для вызовов MCP в этой
конфигурации проверяется заголовок
Authorization: Bearer <token>;
без корректного токена сервер возвращает
401 Unauthorized.
# Из каталога проекта, при активном виртуальном окружении
python server.py
Я отдельно проверял, что с неверным токеном вызвать инструменты
нельзя, а проверка /health не раскрывает данные.
Отключать межсетевой экран ради работы MCP не нужно:
доступ следует разрешать только с тех узлов и портов,
которым он действительно требуется.
Open WebUI
- Откройте настройки интеграций и добавьте MCP-сервер с транспортом Streamable HTTP.
-
Укажите адрес сервера. Если Open WebUI работает в Docker,
для обращения к сервису на хосте может использоваться адрес
http://host.docker.internal:8083/mcp. -
Передайте заголовок авторизации
Authorization: Bearer <MCP_TOKEN>в настройке подключения. - Сохраните конфигурацию и проверьте, что инструменты обнаружены. После этого выберите модель и разрешите агенту вызывать подключённые инструменты.
AnythingLLM
В AnythingLLM сервер можно зарегистрировать в конфигурационном файле списка MCP-серверов. Для Streamable HTTP задаются тип подключения, URL и заголовок авторизации:
{
"mcpServers": {
"training-data": {
"type": "streamable",
"url": "http://host.docker.internal:8083/mcp",
"headers": {
"Authorization": "Bearer <MCP_TOKEN>"
}
}
}
}
После изменения конфигурации нужно обновить список серверов в настройках навыков агента и убедиться, что функции MCP отображаются верно.
Как я проверял результат
Потребовались следующие шаги:
-
Вызвать
database_statusи убедиться, что соединение открывается именно под пользователем с ограниченными правами. - Запросить расписание за известный период и сравнить даты, число занятий и количество слушателей с контрольной выборкой из базы.
- Проверить период, в котором нет занятий, неверный формат даты, обратный порядок дат и превышение ограничения по объёму выдачи.
- Убедиться, что неверный токен блокирует обращение к MCP, а в ответах нет полей, не предусмотренных опубликованными представлениями.
В результате агент возвращает таблицу с названием курса, числом учебных дней, датами, количеством слушателей, форматом обучения, преподавателем и аудиторией. Когда я впервые получил такой ответ на обычный текстовый вопрос, то был крайне рад и ещё несколько часов терзал модель промптами, проверяя, что вся связка работает как надо.
Что в итоге получилось
Вместо прямого доступа модели к базе я получил промежуточный слой с ограниченными правами и понятными операциями. Oracle выполняет SQL-запросы, представления скрывают сложность исходной схемы, MCP-сервер публикует инструменты, а агент выбирает их по смыслу запроса. Каждый компонент делает свою часть работы, и при необходимости его можно проверять отдельно.
Мне хотелось пройти путь самостоятельно, хотя, конечно, в оболочках есть и готовые коннекторы. В моём случае важна была не скорость клика по кнопке, а понимание всей цепочки — от прав пользователя до финальной таблицы в ответе модели. Заодно стало ясно, что подключить ИИ к базе — не значит дать ему пароль от всего хозяйства. Гораздо полезнее заранее решить, какие вопросы он должен уметь задавать данным, и предоставить для этого именно необходимые инструменты.
И ещё: для всех задач по написанию Python- и SQL-кода я активно использовал помощь локальной модели, поэтому получился симбиоз, где сама нейросеть помогала строить её инфраструктуру. Полученным результатом остался крайне доволен, а слушатели моих курсов по направлению LLM Alchemy теперь смогут ознакомиться не только с теорией, но и с практикой механизма интеграции реляционных баз данных.