# VMK Data MCP Server

MCP-сервер (Model Context Protocol) для интеллектуального поиска по базе данных
недвижимости **`vmk_data`**. Предоставляет AI-агентам (Claude, GPT и др.) безопасный
набор инструментов для семантического и полнотекстового поиска объявлений с
фильтрацией, пагинацией и сортировкой по релевантности.

---

## 📋 Содержание

- [Возможности](#возможности)
- [Архитектура](#архитектура)
- [Требования к базе данных](#требования-к-базе-данных)
- [Инструменты MCP](#инструменты-mcp)
- [Быстрый старт](#быстрый-старт)
- [Конфигурация](#конфигурация)
- [Развёртывание](#развёртывание)
- [Безопасность](#безопасность)
- [Разработка](#разработка)
- [Решение проблем](#решение-проблем)

---

## Возможности

| Функция | Технология | Описание |
|---------|-----------|----------|
| **Семантический поиск** | `pgvector` + HNSW + Ollama | Поиск объявлений «по смыслу» через векторную близость (cosine distance) |
| **Полнотекстовый поиск** | PostgreSQL FTS (украинский конфиг) | Поиск по ключевым словам с ранжированием по релевантности (`ts_rank_cd`) |
| **Фильтрация метаданных** | SQL `WHERE` с параметрами | Цена, район, комнаты, метро, тип сделки, статус и др. |
| **Пагинация** | `LIMIT` / `OFFSET` | Настраиваемый размер страницы (1–100) |
| **Read-only безопасность** | `default_transaction_read_only = on` + валидация SQL | Гарантированная защита от записи/изменения данных |
| **Потоковый HTTP** | MCP Streamable HTTP | Поддержка SSE-стрима + POST на порту 8080 |

---

## Архитектура

```
┌─────────────────┐      HTTP (SSE+POST)      ┌──────────────────────────────┐
│   AI Агент      │  ───────────────────────>  │      VMK Data MCP Server      │
│  (Claude/GPT)   │      port 8080 /mcp       │      (FastMCP + Starlette)   │
└─────────────────┘                           └──────────────────────────────┘
                                                           │
                              ┌────────────────────────────┼────────────────────────────┐
                              │                            │                            │
                              ▼                            ▼                            ▼
                    ┌─────────────────┐          ┌─────────────────┐          ┌─────────────────┐
                    │   PostgreSQL   │          │    Ollama API   │          │   Логирование   │
                    │   vmk_data     │          │  nomic-embed-   │          │    (structlog)  │
                    │  + pgvector    │          │    text 768d    │          │                 │
                    └─────────────────┘          └─────────────────┘          └─────────────────┘
```

### Поток данных при семантическом поиске

1. Пользовательский запрос (на любом языке) переводится AI-агентом на **украинский**.
2. MCP-сервер отправляет украинский текст в **Ollama** (`/api/embed`).
3. Полученный вектор (768 float) передаётся в PostgreSQL как `vector`.
4. pgvector выполняет поиск по HNSW-индексу: `embedding <=> $1::vector <= $2`.
5. Результаты сериализуются в JSON и возвращаются агенту.

---

## Требования к базе данных

MCP-сервер рассчитывает, что в PostgreSQL уже существует подготовленная база
`vmk_data` (или любая другая, указанная в `DATABASE_URL`).

### Обязательные расширения PostgreSQL

Подключитесь к базе от имени суперпользователя и выполните:

```sql
-- pgvector — для векторного поиска
CREATE EXTENSION IF NOT EXISTS vector;

-- Для FTS (обычно встроено, но проверьте)
CREATE EXTENSION IF NOT EXISTS pg_trgm;  -- опционально, для ILIKE-оптимизации
```

### Таблица `property_listings`

Сервер читает только из этой таблицы. Она должна содержать следующие колонки:

| Колонка | Тип | Обязательна | Описание |
|---------|-----|-------------|----------|
| `id` | `bigint` | ✅ | Первичный ключ |
| `title` | `text` | ✅ | Заголовок объявления |
| `description` | `text` | ✅ | Описание |
| `generated_description` | `text` | ❌ | AI-сгенерированное описание |
| `price` | `numeric` | ❌ | Цена |
| `currency` | `varchar(3)` | ❌ | `USD`, `EUR`, `UAH` |
| `deal_type` | `varchar` | ❌ | `sale`, `rent_long`, `rent_short` |
| `city` | `varchar` | ❌ | Город (украинский) |
| `district` | `varchar` | ❌ | Район (украинский) |
| `rooms_count` | `int` | ❌ | Количество комнат |
| `total_area` | `float` | ❌ | Общая площадь, м² |
| `living_area` | `float` | ❌ | Жилая площадь, м² |
| `kitchen_area` | `float` | ❌ | Площадь кухни, м² |
| `floor` | `int` | ❌ | Этаж |
| `floors_count` | `int` | ❌ | Этажность дома |
| `building_type` | `varchar` | ❌ | `brick`, `panel`, `monolith`, `gas_block`, `wood` |
| `building_year` | `int` | ❌ | Год постройки |
| `renovation_status` | `varchar` | ❌ | Статус ремонта |
| `balcony_count` | `int` | ❌ | Количество балконов |
| `bathroom_type` | `varchar` | ❌ | Тип санузла |
| `parking_type` | `varchar` | ❌ | Тип парковки |
| `heating_type` | `varchar` | ❌ | Тип отопления |
| `layout_type` | `varchar` | ❌ | Тип планировки |
| `window_view` | `varchar` | ❌ | Вид из окон |
| `metro_station` | `varchar` | ❌ | Станция метро |
| `metro_distance_type` | `varchar` | ❌ | `walking`, `transport` |
| `metro_distance_meters` | `int` | ❌ | Расстояние до метро, м |
| `url_source` | `text` | ❌ | Ссылка на источник |
| `publish_date` | `date` | ❌ | Дата публикации |
| `images_count` | `int` | ❌ | Количество фото |
| `contact_phone` | `varchar` | ❌ | Телефон контакта |
| `listing_status` | `varchar` | ❌ | `active`, `sold`, `rented`, `removed`, `archived` |
| `archived_at` | `date` | ❌ | Дата архивации |
| `created_at` / `updated_at` | `date` или `timestamp` | ❌ | Служебные таймстампы |
| `embedding` | `vector(768)` | ✅ | **Вектор эмбеддинга** (pgvector) |
| `search_vector` | `tsvector` | ✅ | **Полнотекстовый индекс** (украинский конфиг) |

> ℹ️ Если в вашей базе нет каких-то колонок (например, `generated_description` или `kitchen_area`),
> сервер всё равно запустится, но эти поля будут возвращаться как `null`.
> **Обязательны** только `id`, `title`, `description`, `embedding`, `search_vector`.

### Индексы (обязательны для производительности)

```sql
-- Векторный HNSW-индекс (cosine distance)
CREATE INDEX IF NOT EXISTS property_listings_embedding_idx
ON property_listings
USING hnsw (embedding vector_cosine_ops);

-- Полнотекстовый GIN-индекс
CREATE INDEX IF NOT EXISTS property_listings_search_vector_idx
ON property_listings
USING gin (search_vector);
```

### Генерация `search_vector` (если у вас его ещё нет)

Если колонка `search_vector` отсутствует, создайте её как **generated column**:

```sql
ALTER TABLE property_listings
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('ukrainian', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('ukrainian', coalesce(description, '')), 'B') ||
    setweight(to_tsvector('ukrainian', coalesce(city, '')), 'C') ||
    setweight(to_tsvector('ukrainian', coalesce(district, '')), 'C') ||
    setweight(to_tsvector('ukrainian', coalesce(metro_station, '')), 'B')
) STORED;

-- Пересоздать GIN-индекс после добавления колонки
CREATE INDEX property_listings_search_vector_idx
ON property_listings USING gin (search_vector);
```

> ⚠️ Если украинский конфиг `ukrainian` отсутствует в `SELECT cfgname FROM pg_ts_config;`,
> замените `'ukrainian'` на `'simple'`.

### Генерация `embedding` (если колонка пустая)

Для заполнения `embedding` в существующих записях используйте внешний скрипт
(не входит в MCP-сервер). Примерная логика:

```python
# Псевдокод — выполняется отдельно от MCP-сервера
for batch in chunks(listings_without_embedding, 100):
    texts = [f"{l.title}. {l.description}" for l in batch]
    vectors = ollama.embed(model="nomic-embed-text", input=texts)
    for l, v in zip(batch, vectors):
        UPDATE property_listings SET embedding = v::vector WHERE id = l.id
```

Сервер ожидает, что эмбеддинги уже сгенерированы и сохранены в БД.

---

## Инструменты MCP

Сервер регистрирует 4 инструмента, доступных через MCP-протокол:

### `search_similar_listings`
**Векторный (семантический) поиск** — находит объявления, близкие по смыслу к запросу.

**Входные параметры:**
- `query` *(string, обязательный)* — текст на **украинском** для эмбеддинга
- `filters` *(object)* — фильтры метаданных (см. [Фильтры](#фильтры))
- `pagination` *(object)* — `limit` (1–100, по умолч. 20), `offset` (≥ 0)
- `min_similarity` *(float)* — порог косинусной близости (0.0–1.0, по умолч. 0.7)

**Выход:** [`SearchResult`](#searchresult) — список объявлений с полем `similarity_score`.

> ⚠️ Параметр `min_similarity` преобразуется в максимальное косинусное расстояние:
> `max_distance = 2.0 × (1.0 − min_similarity)`, потому что оператор `<=>` в pgvector
> возвращает расстояние в диапазоне **[0, 2]**.

---

### `search_by_metadata`
**Полнотекстовый поиск (FTS)** — ищет по ключевым словам в `search_vector`.

**Входные параметры:**
- `query` *(string, обязательный)* — текстовый запрос на украинском
- `filters` *(object)* — те же фильтры, что и для векторного поиска
- `pagination` *(object)* — пагинация

**Выход:** [`SearchResult`](#searchresult) — список с полем `rank_score` (релевантность FTS).

---

### `get_listing_by_id`
**Получение объявления по ID** — точечная выборка одной записи.

**Входные параметры:**
- `listing_id` *(integer, обязательный)* — `id` объявления

**Выход:** [`ListingResult`](#listingresult) или `{"error": "..."}` если не найдено.

---

### `describe_schema`
**Описание схемы БД** — возвращает структуру таблицы `property_listings`
(колонки, типы, индексы) для подсказок AI-агенту при формировании запросов.

**Входных параметров нет.**

**Выход:** JSON-описание схемы.

---

## Быстрый старт

### Вариант А: Локальный запуск (для разработки)

**Шаг 1 — Клонирование и окружение**

```bash
git clone <repo-url>
cd data_mcp
python -m venv .venv
source .venv/bin/activate      # Linux / macOS
# .venv\Scripts\activate       # Windows PowerShell
pip install -e "."
```

**Шаг 2 — Настройка `.env`**

Скопируйте пример и отредактируйте:

```bash
cp .env.example .env
nano .env   # или vim / VS Code
```

Минимально необходимые переменные:

```env
# Куда подключаться к PostgreSQL
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/vmk_data

# Куда обращаться за эмбеддингами
OLLAMA_BASE_URL=http://192.168.1.75:11434
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_EMBED_DIMENSIONS=768

# На каком порту слушать MCP
MCP_PORT=8080
```

> 🔍 Где взять значения:
> - `DATABASE_URL` — DSN вашей PostgreSQL с базой `vmk_data`.
> - `OLLAMA_BASE_URL` — URL хоста, где запущена Ollama. Если на той же машине — `http://localhost:11434`.
> - `OLLAMA_EMBED_MODEL` — должна совпадать с моделью, которой вы генерировали `embedding` в БД.

**Шаг 3 — Проверка зависимостей**

```bash
# Проверьте, что Ollama отвечает
curl http://localhost:11434/api/tags

# Проверьте, что PostgreSQL доступна
psql "${DATABASE_URL}" -c "SELECT COUNT(*) FROM property_listings;"
```

**Шаг 4 — Запуск**

```bash
python -m vmk_data_mcp.main
```

В терминале появится:
```
INFO:     Started server process [12345]
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
```

MCP endpoint доступен на `http://localhost:8080/mcp`.

### Вариант Б: Docker Compose

**Шаг 1 — Настройка `.env`**

```bash
cp .env.example .env
# Отредактируйте — для Docker обычно нужен host.docker.internal
```

Пример `.env` для Docker на Linux:

```env
DATABASE_URL=postgresql://postgres:postgres@host.docker.internal:5433/vmk_data
OLLAMA_BASE_URL=http://host.docker.internal:11434
MCP_PORT=8080
```

> `host.docker.internal` — это хост-машина. Убедитесь, что PostgreSQL и Ollama
> слушают не только `127.0.0.1`, а также внешний интерфейс (или настроен проброс портов).

**Шаг 2 — Сборка и запуск**

```bash
docker-compose up --build -d
```

Проверка:
```bash
curl http://localhost:8080/mcp
docker logs -f vmk-data-mcp
```

Остановка:
```bash
docker-compose down
```

---

## Конфигурация

Все параметры задаются через `.env` или переменные окружения.

### Обязательные переменные

| Переменная | Пример значения | Откуда взять |
|------------|-----------------|--------------|
| `DATABASE_URL` | `postgresql://user:pass@host:5432/vmk_data` | DSN вашей PostgreSQL |
| `OLLAMA_BASE_URL` | `http://192.168.1.75:11434` | URL сервера Ollama |
| `OLLAMA_EMBED_MODEL` | `nomic-embed-text` | Модель, использованная для генерации `embedding` в БД |
| `OLLAMA_EMBED_DIMENSIONS` | `768` | Размерность вектора модели (768 для `nomic-embed-text`) |

### Опциональные переменные

| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `DB_POOL_MIN_SIZE` | `2` | Минимум соединений в пуле asyncpg |
| `DB_POOL_MAX_SIZE` | `10` | Максимум соединений в пуле |
| `DB_QUERY_TIMEOUT` | `30` | Таймаут SQL-запросов, секунды |
| `OLLAMA_REQUEST_TIMEOUT` | `60.0` | Таймаут HTTP-запроса к Ollama, секунды |
| `MCP_SERVER_NAME` | `vmk-data-mcp` | Имя сервера в MCP-протоколе |
| `MCP_PORT` | `8080` | Порт HTTP-транспорта |

> 💡 Совет: если Ollama и PostgreSQL работают локально, минимальный `.env` — это
> всего 4 строчки (см. раздел **Быстрый старт → Вариант А**).

---

## Развёртывание

### Требования к инфраструктуре

- **PostgreSQL 15+** с расширением `pgvector`
- **Ollama** с моделью `nomic-embed-text` (768d):
  ```bash
  ollama pull nomic-embed-text
  ```
- Python 3.11+ (если запускаете без Docker)

### Docker (один контейнер)

```bash
docker build -t vmk-data-mcp .
docker run -d \
  -p 8080:8080 \
  -e DATABASE_URL=postgresql://... \
  -e OLLAMA_BASE_URL=http://... \
  --name vmk-data-mcp \
  vmk-data-mcp
```

### Docker Compose (рекомендуется)

Файл `docker-compose.yml` уже в репозитории. Он использует `host.docker.internal`
для доступа к PostgreSQL и Ollama на хост-машине.

> **Linux:** `host.docker.internal` работает благодаря `extra_hosts` в `docker-compose.yml`.
> На Windows/macOS это работает из коробки.

---

## Безопасность

### Read-only гарантия

1. **На уровне соединения:** каждое новое соединение получает
   `SET default_transaction_read_only = on`.
2. **На уровне строки:** входящий SQL проверяется через `_is_safe_query`:
   - только префиксы `select`, `with`, `values`, `explain`
   - запрещён символ `;` внутри строки (блокировка multi-statement атак)
3. **На уровне колонок:** только `USER_COLUMNS` участвуют в `SELECT`;
   попытка запросить другую колонку вызывает ошибку.

### SQL-инъекции

Все пользовательские данные передаются через **параметризованные запросы** (`$1, $2 …`).
Ни один пользовательский параметр не интерполируется в строку SQL.

---

## Разработка

### Тесты

```bash
pytest -q
```

8 тестов покрывают:
- границы пагинации (`limit` / `offset`)
- валидацию фильтров
- сериализацию моделей

### Линтинг

```bash
ruff check src tests
ruff format src tests
```

### Структура проекта

```
data_mcp/
├── src/vmk_data_mcp/
│   ├── __init__.py
│   ├── main.py          # FastMCP сервер + HTTP transport
│   ├── tools.py         # Реализация 4 инструментов
│   ├── models.py        # Pydantic-модели входных/выходных данных
│   ├── db.py            # asyncpg пул, read-only защита, USER_COLUMNS
│   ├── embedder.py      # Ollama HTTP клиент для эмбеддингов
│   └── config.py        # Настройки из .env (pydantic-settings)
├── tests/
│   └── test_models.py   # Pytest
├── docs/
│   ├── API.md           # JSON-схемы и примеры запросов
│   └── ARCHITECTURE.md  # Архитектура и data flow
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
├── .env.example
└── README.md
```

---

## Решение проблем

### Ollama недоступна

```
Сервис эмбеддингов недоступен: ConnectError(...)
```
- Проверьте `OLLAMA_BASE_URL`
- Убедитесь, что Ollama слушает на `0.0.0.0` (не только `127.0.0.1`):
  ```bash
  # На Linux: проверьте переменную окружения
  echo $OLLAMA_HOST  # должно быть 0.0.0.0 или пусто
  ```
- Проверьте firewall / Docker network

### Нет результатов при семантическом поиске

- Убедитесь, что запрос переведён на **украинский** перед вызовом инструмента.
- Проверьте порог `min_similarity` — слишком высокий (0.95+) может
  исключить все записи.
- Убедитесь, что в БД заполнена колонка `embedding` (не NULL):
  ```sql
  SELECT COUNT(*) FROM property_listings WHERE embedding IS NOT NULL;
  ```

### PostgreSQL: отсутствует `vector`

```sql
CREATE EXTENSION vector;
```

### Неправильная размерность эмбеддинга

Сервер ожидает `OLLAMA_EMBED_DIMENSIONS=768` (модель `nomic-embed-text`).
Если используется другая модель — обновите переменную окружения.

### `search_vector` не найдена

Если в БД нет колонки `search_vector` — создайте её как generated column
(см. раздел [Требования к базе данных → Генерация search_vector](#требования-к-базе-данных)).

---

## Лицензия

MIT
