mcp-dwh
by ivanmandat
README.md
# mcp-dwh
MCP-сервер, который даёт агентам строить дашборды и отчёты по хранилищу на
ClickHouse — и переживает изменения его структуры без правок кода.
Схема **не вкладывается в промпт**: агент добывает её вызовами интроспекции,
поэтому добавление таблиц, переименование колонок и даже замена предметной
области целиком не требуют ни правок сервера, ни обновления инструкций.
**Документация архитектуры:**
[Живая схема](docs/architecture.html) ·
[Спек дашборда](docs/dashboard-spec.html) — откройте файлы в браузере.
---
## Что это делает
| | |
|---|---|
| **Интроспекция** | Агент ищет таблицы, читает типы, комментарии, ключ сортировки, профилирует колонки |
| **Семантический слой** | Метрики, join-граф, подсказки по значениям — в Postgres, отдельно от физической схемы |
| **Дашборды** | Декларативные спеки с версиями, уровнями поддержки и валидацией против актуальной схемы |
| **Рендер** | Push в Grafana (живой дашборд) или самодостаточный HTML-отчёт (срез на момент времени) |
| **Реакция на миграции** | Снимки схемы, дифф, автоматический перевод устаревшего в `stale` и `orphaned` |
30 инструментов, 53 теста, четыре сервиса в compose.
---
## Быстрый старт на Linux
Нужен Docker с плагином compose. Всё остальное скрипт сделает сам: сгенерирует
секреты, поднимет ClickHouse, Postgres, Grafana и MCP, создаст слои хранилища
и наполнит их тестовыми данными.
```bash
git clone <репозиторий> mcp-dwh && cd mcp-dwh
./scripts/setup.sh
```
Полный прогон с чистого листа — около двух минут. По окончании скрипт напечатает
адреса и подскажет, где взять токен.
```bash
./scripts/setup.sh --no-seed # только структура, без тестовых данных
./scripts/setup.sh --recreate # снести тома и начать с нуля
./scripts/setup.sh --domain sales # другой набор SQL (по умолчанию tutoring)
```
Скрипт идемпотентен: повторный запуск пересоздаёт слои, но **не трогает уже
созданные секреты**.
### Что поднимется
| Сервис | Порт | Назначение |
|---|---|---|
| `mcp-server` | 8765 | MCP по HTTP, требует Bearer-токен |
| `mcp-clickhouse` | 8123, 9000 | Хранилище |
| `mcp-postgres` | 5433 | Семантический слой и дашборды |
| `mcp-grafana` | 3000 | Рендер живых дашбордов |
Все порты публикуются **только на петлевой интерфейс**. Перед выставлением
наружу поставьте обратный прокси с TLS: Bearer-токен без шифрования канала
бессмысленен.
### Установка Docker, если его нет
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" && newgrp docker
```
Проверка: `docker compose version` должна вывести v2 или выше.
---
## Быстрый старт на Windows
Docker Desktop, PowerShell 5.1 и уже установленный PostgreSQL.
```powershell
powershell -ExecutionPolicy Bypass -File scripts\bootstrap.ps1
powershell -ExecutionPolicy Bypass -File scripts\init_postgres.ps1
```
Альтернатива — запустить `scripts/setup.sh` из Git Bash: он работает и там,
поднимая Postgres в контейнере вместо установленного на хосте.
### Если Docker не поднимается
На машине разработки встретились две независимые проблемы.
**1. Служба WSL отключена** (`Wsl/0x80070422`). Разово от администратора:
```powershell
Set-Service -Name WSLService -StartupType Manual
Start-Service -Name WSLService
Start-Service -Name com.docker.service
```
**2. Осиротевшие Unix-сокеты.** Бэкенд падает с
`initializing Inference manager: ... The file cannot be accessed by the system`.
Здесь `<HOME>` в логе — маскировка домашнего каталога, а не незаполненный
шаблон. Файл сокета остался от аварийного завершения, стал битым reparse point
и не удаляется ничем — ни `Remove-Item`, ни `fsutil reparsepoint delete`.
Лечится переименованием каталога, Docker создаёт его заново:
```powershell
Move-Item "$env:LOCALAPPDATA\Docker\run" "$env:LOCALAPPDATA\Docker\run.broken"
Move-Item "$env:LOCALAPPDATA\docker-secrets-engine" "$env:LOCALAPPDATA\docker-secrets-engine.broken"
```
Каждая неудачная попытка старта плодит новый такой сокет, поэтому чистить надо
непосредственно перед запуском.
---
## Подключение клиента
### Локально, режим stdio
Клиент сам запускает процесс, строки подключения не существует.
Готовый конфиг — [.cursor/mcp.json](.cursor/mcp.json):
```json
{
"mcpServers": {
"mcp-dwh": {
"command": "/путь/к/.venv/bin/mcp-dwh",
"args": []
}
}
}
```
### Удалённо, режим HTTP
Шаблон — [deploy/client-remote.json](deploy/client-remote.json):
```json
{
"mcpServers": {
"mcp-dwh": {
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ВАШ_ТОКЕН" }
}
}
}
```
Путь именно `/mcp`. Обращение к `/mcp/` даёт редирект 307, а часть клиентов
теряет на нём заголовок `Authorization`.
---
## Настройка
Только переменные окружения. Локально подхватываются из `.env` рядом с
`pyproject.toml`, в контейнере передаются compose-ом.
### ClickHouse
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `CH_HOST` | `127.0.0.1` | Адрес сервера |
| `CH_HTTP_PORT` | `8123`, при `CH_SECURE` — `8443` | Порт HTTP-интерфейса |
| `CH_DASHBOARD_USER` | `dashboard` | Пользователь только для чтения |
| `CH_SECURE` | `false` | **TLS. Обязателен, когда база на другом сервере** |
| `CH_VERIFY` | `true` | Проверять сертификат |
| `CH_ALLOWED_DATABASES` | `core,mart` | Слои, видимые агенту |
| `CH_DEFAULT_LIMIT` / `CH_MAX_LIMIT` | `200` / `5000` | Обрезка выдачи |
| `CH_POOL_SIZE` | `8` | Размер пула клиентов |
`CH_ALLOWED_DATABASES` — не про безопасность, её обеспечивают гранты на стороне
ClickHouse. Список нужен, чтобы интроспекция не показывала агенту лишнего, и
настраивается, потому что у заказчика слои могут называться иначе.
### Postgres, Grafana, сервер
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `PG_DSN` | — | Обязательна |
| `PG_POOL_MIN` / `PG_POOL_MAX` | `1` / `8` | Размер пула |
| `GF_URL` | `http://127.0.0.1:3000` | Нужна только для `dash_publish` |
| `MCP_TRANSPORT` | `stdio` | `stdio` или `http` |
| `MCP_PORT` | `8765` | Порт HTTP-режима |
| `MCP_REPORTS_DIR` | корень проекта или `/app/reports` | Куда складывать отчёты |
### Секреты
Пароли и токены **не передаются через переменные окружения**: содержимое env
видно в `docker inspect` и попадает в логи оркестратора. Вместо этого — файлы
в каталоге `secrets/` (в git не попадает).
```
secrets/
ch_admin_password пароль default в ClickHouse
ch_dashboard_password пароль пользователя дашбордов
gf_admin_password админ Grafana
mcp_auth_token Bearer-токен MCP-сервера
pg_dsn строка Postgres для контейнера
pg_dsn_local то же для запуска с хоста
pg_password пароль Postgres
```
| Компонент | Механизм |
|---|---|
| ClickHouse | `CLICKHOUSE_PASSWORD_FILE` — поддержка в entrypoint официального образа |
| Grafana (админ) | `GF_SECURITY_ADMIN_PASSWORD__FILE` — двойное подчёркивание, конвенция Grafana |
| Grafana (источник данных) | `$__file{/run/secrets/...}` прямо в файле провижининга |
| MCP-сервер | `<ИМЯ>_FILE` — та же идиома, реализована в `config.py` |
`.env` содержит только несекретные настройки и указатели вида
`CH_DASHBOARD_PASSWORD_FILE=secrets/ch_dashboard_password`.
Проверка: `docker inspect --format '{{json .Config.Env}}' mcp-server` — в выводе
должны быть только пути `*_FILE`, ни одного значения.
Для Swarm или Kubernetes определения `secrets:` в compose меняются с `file:` на
внешние. Код сервера при этом не трогается: он читает всё те же `/run/secrets/*`.
**Изменили секрет — пересоздайте контейнер.** Compose не отслеживает содержимое
файлов: `docker compose up -d --force-recreate mcp`.
### Диагностика
Инструмент `mcp_diagnostics` показывает, к каким адресам сервер обращается
на самом деле, и проверяет связь со всеми тремя системами. Пароли скрыты.
Вызывайте первым при ошибках подключения.
---
## Слои хранилища
| Слой | Назначение | Доступ пользователя `dashboard` |
|---|---|---|
| `raw` | Сырые выгрузки: всё строками, с дублями и разнобоем значений | нет |
| `int` | Типизация, дедупликация, нормализация | нет |
| `core` | Очищенные факты и измерения, минимум бизнес-логики | SELECT |
| `mart` | OBT и агрегаты под дашборды | SELECT |
**Гранты кодируют слоистость**: агент физически не может построить дашборд на
сыром или промежуточном слое — они не видны даже в `system.tables`. Это
надёжнее, чем просить его об этом в промпте.
### Тестовые данные
Оба домена **вымышлены**. Это синтетические стенды, а не выгрузка чьей-либо
системы: структура придумана так, чтобы быть правдоподобной и содержать
типовые сложности реального хранилища. Никаких настоящих данных, схем или
бизнес-правил конкретной компании здесь нет.
По умолчанию разворачивается домен **репетиторства**: занятия с репетитором,
домашние работы, экзамены. Около 219 тысяч занятий, 126 тысяч домашних работ,
3 тысячи учеников, 120 репетиторов за 2024-01-01 … 2025-08-31.
Данные не случайны: в них есть тренд роста, летняя просадка и **связь между
числом занятий и сдачей экзамена** — от 0% при менее чем 20 занятиях до 100%
при сотне. Это позволяет проверять, что дашборды показывают осмысленное.
Второй домен, **продажи**, остался в `sql/` и разворачивается через
`--domain sales`. Он существует не для полноты, а как инструмент проверки:
переключение между двумя непохожими доменами показывает, что сервер
действительно не знает предметной области.
### Ловушки, заложенные намеренно
Тестовая база содержит те грабли, которые семантический слой обязан
документировать:
- статус приходит в разном регистре: `ACTIVE`, `active`, `PAUSED`, `churned`;
- поле `revenue` уже обнулено для несостоявшихся занятий, а `price` — нет:
суммировать нужно первое;
- тестовые аккаунты отсекаются только на слое `core` — правило бизнесовое,
из схемы не выводится;
- у части учеников нет класса (взрослые), у части событий — идентификатора;
- в `raw` есть дубли по ключу: брать нужно последнюю запись по `_ingested_at`.
---
## Безопасность
- **`readonly = 2` с потолками**, а не `readonly = 1`. Значение 1 запрещает
любой `SET`, и на этом падает плагин ClickHouse для Grafana: он выставляет
`max_execution_time` на каждый запрос. Гарантия возвращена секцией
`constraints` — настройку можно менять только в пределах потолка, а сами
`readonly` и `allow_ddl` объявлены неизменяемыми.
- Квоты: время выполнения, число прочитанных и возвращённых строк, память,
часовая квота на пользователя.
- Принудительный `LIMIT` на уровне MCP — только чтобы не топить контекст
агента; защиту базы обеспечивает сервер.
- HTTP-режим не стартует без `MCP_AUTH_TOKEN`.
Проверено семь запретов: поднять таймаут выше потолка (код 452), снять
`readonly` (164), разрешить DDL (392), поднять лимит строк (452), запись,
создание таблиц и чтение слоя `raw` (497).
### Про монтирование конфигов
В compose монтируются **отдельные файлы**, а не каталоги `config.d` и `users.d`.
Bind-mount каталога скрывает то, что кладёт в него образ:
- в `config.d` образ ClickHouse кладёт `docker_related_config.xml` с
`listen_host = 0.0.0.0`; если его затенить, сервер слушает только localhost
внутри контейнера, и проброс портов перестаёт работать — при этом
`docker exec clickhouse-client` продолжает отвечать, потому что выполняется
внутри. Симптом обманчивый: порт на хосте слушается, но ответ пустой;
- в `users.d` entrypoint дописывает `default-user.xml`; каталог, смонтированный
как `:ro`, отправляет контейнер в цикл перезапуска.
---
## Разработка
```bash
uv venv --python 3.10
uv pip install -e .
.venv/bin/python -m pytest tests -q
```
Тесты **не знают предметной области**: имена таблиц, колонок и метрик
добываются интроспекцией через фикстуры в `tests/conftest.py`. Это не
аккуратность ради аккуратности — при смене домена с продаж на репетиторство
продуктовый код не потребовал правок, а 9 тестов упали именно потому, что
ссылались на конкретные имена.
Интеграционные тесты пропускаются, если сервисы не подняты.
### Структура
```
docs/ архитектура и спек дашборда (HTML)
src/mcp_dwh/
config.py настройки, секреты из файлов
clickhouse.py доступ к ClickHouse через пул
pool.py, db.py пулы соединений
introspect.py интроспекция схемы
semantic.py семантический слой
dashboards.py хранение дашбордов, уровни поддержки
validation.py валидация виджетов
grafana.py адаптер рендера
reports.py HTML-отчёты
schema_watch.py снимки схемы, дифф, реакция
http_server.py HTTP-транспорт, Bearer-аутентификация
server.py регистрация инструментов MCP
sql/ домен продаж + общие файлы
sql/tutoring/ домен репетиторства
sql/pg/ схема семантического слоя (доменно-нейтральна)
scripts/setup.sh развёртывание на Linux
scripts/bootstrap.ps1 развёртывание на Windows
```
---
## Это MVP: как встраивается в реальный стек
Проект собран самодостаточным, чтобы его можно было запустить и потрогать.
В боевой инфраструктуре часть деталей заменяется, а ядро остаётся.
| Компонент | Здесь (MVP) | В боевом стеке |
|---|---|---|
| Загрузка в `raw` | SQL-генератор данных | **dlt** со своими `_dlt_load_id` и служебными таблицами |
| Преобразования слоёв | `sql/*/60_transform.sql` | **dbt** — мои скрипты не нужны целиком |
| Описания, линиж, допустимые значения | Руками в `sem.*` | Из `manifest.json` dbt: `description`, `depends_on`, тесты `accepted_values` и `relationships` |
| Расписание | Вручную | **Prefect** — нужен CLI или HTTP-эндпоинты, по MCP оркестратор не ходит |
| Рендер дашбордов | Адаптер Grafana | **Datalens** — заменяется один модуль, ради этого адаптер и делался сменным |
| HTML-отчёты | — | Без изменений: BI здесь не участвует |
**Ядро не зависит от краёв.** Интроспекция, спеки дашбордов и валидатор
обращаются к `system.tables` и `system.columns`, а не к тому, чем эти таблицы
наполнены. Заменяются только края — источник описаний, планировщик и таргет
рендера.
Главное следствие: **семантический слой не должен дублировать dbt.** Описания
моделей и колонок команда уже поддерживает в `schema.yml` под ревью в PR;
держать их ещё и в Postgres значит гарантированно получить расхождение.
За MCP остаётся то, чего в dbt нет: профилирование фактических значений
(`accepted_values` описывает намерение, профиль — что в таблице лежит на самом
деле) и записи, дописанные агентом по ходу работы.
Подробнее, с диаграммой и разбором по компонентам —
в [Живой схеме](docs/architecture.html), раздел «Интеграция с инфраструктурой
заказчика». Там же перечислено, что нужно выяснить: используется ли dbt
Semantic Layer, насколько полон API Datalens, есть ли доступ к манифесту.
---
## Что сделано и что дальше
Полный статус с обоснованиями — в [Живой схеме](docs/architecture.html),
разделы «Что реализовано» и «Возможности развития».
**Работает:** интроспекция, семантический слой, спеки дашбордов с уровнями
поддержки, валидатор с четырьмя статусами виджета, адаптер Grafana,
HTML-отчёты, снимки схемы и реакция на миграции, HTTP-транспорт с
аутентификацией, пулы соединений, файловые секреты.
**Направления развития**, по убыванию отношения пользы к трудозатратам:
1. **Читатель `manifest.json`.** Убирает дублирование описаний с dbt —
наибольшая польза и снятие риска расхождения.
2. **Адаптер Datalens.** Grafana здесь MVP-таргет; сначала разведка API.
3. **CLI для Prefect.** Логика снимков и валидации готова, не хватает вызова
извне MCP.
4. **Автообогащение черновиков.** С dbt сильно упрощается: описания приходят
из манифеста, агенту остаётся профилирование значений.
5. **Агент-починщик.** `dash_list_broken` уже отдаёт ошибку и актуальную
схему — не хватает цикла, который скармливает это агенту; естественное
место для него — flow в Prefect.
6. **Разделение прав.** Любой обладатель токена может заархивировать чужой
дашборд. Молчаливое затирание уже закрыто проверкой занятого uid.
7. **Приоритет очереди ревью** по частоте использования таблиц из
`system.query_log`.
8. **Кросс-фильтрация и drill-down** — только на собственном рендерере,
между таргетами не портируется.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues