Skip to main content
Glama
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** — только на собственном рендерере,
   между таргетами не портируется.