# MCP-сервер для пошуку по базі даних нерухомості

## Контекст
Проєкт: **vmk-data-mcp** — MCP-сервер для vector + metadata пошуку по PostgreSQL-базі `vmk_data` (оголошення про нерухомість). Сервер надає інструменти AI-агенту (Claude) для семантичного та структурованого пошуку оголошень.

**Мова**: база даних містить переважно **український** текст. Всі текстові запити до `search_similar_listings` та `search_by_metadata` повинні бути на українській мові. AI-агент самостійно перекладає запити користувача перед викликом інструментів.

## Архітектурні рішення

### Стек
- **Python 3.11+**
- **MCP Python SDK v1.x** (`FastMCP`) — стабільна версія
- **Transport**: `streamable-http` (рекомендований у 2025 замість SSE)
- **БД**: PostgreSQL + pgvector (вже розгорнуті в `data_collector`)
- **Драйвер**: `asyncpg` (async) + `pgvector.asyncpg` для типів
- **Конфіг**: Pydantic Settings (`pydantic-settings`) з `.env`
- **Embeddings**: `nomic-embed-text` (768d) через Ollama API (`ollama` SDK або `httpx`)

### Принципи безпеки
- **Тільки SELECT**: сервер відкриває read-only пул з'єднань (`SET default_transaction_read_only = on`)
- **Параметризовані запити**: захист від SQL-ін'єкцій
- **Обмеження**: `MAX_LIMIT = 50`, `QUERY_TIMEOUT = 10s`
- **Жодного DDL/DML**: запити будуються через whitelist колонок
- **Embeddings isolation**: клієнт передає `query_text`, сервер сам генерує embedding через внутрішній embedder

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

```
vmk-data-mcp/
├── src/
│   └── vmk_data_mcp/
│       ├── __init__.py
│       ├── main.py                 # точка входу, запуск FastMCP (streamable-http)
│       ├── config.py               # Pydantic Settings (DB, Ollama, server)
│       ├── db.py                   # asyncpg pool + register_vector + read-only guard
│       ├── embedder.py             # абстракція Embedder (Ollama nomic-embed-text)
│       ├── tools/
│       │   ├── __init__.py
│       │   ├── search.py           # векторний пошук: query_text → embedding → pgvector
│       │   ├── listings.py         # отримання оголошення за ID
│       │   ├── metadata_search.py  # текстовий/структурований пошук (FTS + фільтри)
│       │   └── schema_info.py      # статичний опис схеми, enums, фільтри, приклади
│       └── resources/              # відкладено до наступних ітерацій
│           └── __init__.py
├── pyproject.toml
├── .env.example
├── Dockerfile
└── README.md
```

## Компоненти

### Embedder (`embedder.py`)
```python
class Embedder(Protocol):
    async def embed(self, texts: list[str]) -> list[list[float]]: ...

class OllamaEmbedder:
    """Ollama API для nomic-embed-text (768d)."""
    # Конфіг: OLLAMA_HOST, OLLAMA_MODEL=nomic-embed-text
    # Використовує ollama.embed() або httpx POST /api/embed
    # Кешування розмірності на старті (validate 768d)
```

### DB (`db.py`)
- `asyncpg.create_pool(...)` з `min_size`/`max_size` з конфіга
- `register_vector(conn)` при ініціалізації з'єднання
- `pgvector` типи: `Vector(768)`
- Read-only guard: `SET default_transaction_read_only = on` при коннекті
- Query timeout: `asyncio.wait_for` або `command_timeout` в asyncpg

## Інструменти (Tools)

### 1. `search_similar_listings`
Семантичний векторний пошук. Клієнт передає текст українською, сервер сам генерує embedding і шукає найближчих сусідів.

**Вхідні параметри**:
- `query_text`: `str` — текстовий запит українською (наприклад: "2-кімнатна квартира біля метро, новобудова")
- `limit`: `int` (default=10, max=50)
- `offset`: `int` (default=0)
- `filters`: `dict` — опціональні фільтри (whitelist полів):
  - `city`, `district`, `deal_type`, `property_type_id`
  - `min_price`, `max_price`
  - `min_rooms`, `max_rooms`
  - `listing_status` (default=`active`)
  - `min_area`, `max_area`
  - `has_balcony`, `building_type`, `renovation_status`
- `distance_metric`: `enum` — `cosine` (default), `l2`, `inner_product`

**Логіка**:
1. Генерувати `embedding[768]` через `Embedder.embed([query_text])`
2. Побудувати `WHERE` з безпечного whitelist (parametrized)
3. Виконати:
   ```sql
   SELECT id, title, description, price, city, district, rooms_count,
          embedding <=> $1 AS distance
   FROM property_listings
   WHERE ...
   ORDER BY distance
   LIMIT $n OFFSET $m
   ```
4. Повернути JSON: список оголошень + `similarity_score` (1 - distance для cosine)

**Сортування**: за `distance` ASC (чим менше, тим релевантніше). Для cosine: `similarity_score = 1 - distance`.

---

### 2. `search_by_metadata`
Текстовий/структурований пошук без векторів, з сортуванням та пагінацією. Використовує готову FTS-колонку `search_vector` з `data_collector`.

**Вхідні параметри**:
- `query`: `str | None` — пошук по `search_vector` (FTS) або `title`/`description`/`address_raw` (ILIKE fallback)
- `filters`: ті ж, що в `search_similar_listings`
- `sort_by`: `enum` — `relevance` (default), `price_asc`, `price_desc`, `date_desc`, `area_desc`
- `limit`: `int` (default=10, max=50)
- `offset`: `int` (default=0)

**Логіка**:
1. Якщо `query` задано:
   - **Primary**: `WHERE search_vector @@ plainto_tsquery('ukrainian', $1)` (використовує GIN-індекс `ix_property_listings_search_vector_gin`)
   - **Fallback**: якщо FTS не дав результатів — `WHERE (title ILIKE $1 OR description ILIKE $1 OR address_raw ILIKE $1)`
2. Застосувати `filters` (whitelist)
3. Сортування:
   - `relevance`: `ts_rank_cd(search_vector, plainto_tsquery('ukrainian', $1)) DESC` (використовує готову колонку)
   - `price_asc`/`price_desc`: `price ASC/DESC`
   - `date_desc`: `publish_date DESC`
   - `area_desc`: `total_area DESC`
4. `LIMIT $n OFFSET $m`
5. `total_count`: окремий `SELECT COUNT(*)` з тими ж WHERE-умовами

**Вихід**: список оголошень + `total_count` (для UI пагінації)

---

### 3. `get_listing_by_id`
Повна картка оголошення.

**Вхідні параметри**:
- `listing_id`: `int`

**Вихід**: всі "пользовательські" поля `property_listings` + `property_types.name` (JOIN). Поля включають: `title`, `description`, `generated_description`, `price`, `currency`, `city`, `district`, `rooms_count`, `total_area`, `floor`, `building_type`, `metro_station`, `url_source`, `publish_date`, `images_count`, `contact_phone`, `contact_name`, та інші. Без службових (`raw_data_id`, `embedding`, `created_at`, `updated_at`).

---

### 4. `describe_schema`
Статичний опис схеми. Дані захардкожені з `data_collector` моделей.

**Вхідні параметри**: відсутні

**Вихід**:
```json
{
  "tables": [
    {
      "name": "property_listings",
      "description": "Оголошення про нерухомість",
      "columns": ["id", "title", "description", "price", "city", "rooms_count", ...],
      "indexes": [
        "ix_property_listings_embedding_hnsw (hnsw vector_cosine_ops)",
        "ix_property_listings_search_vector_gin (gin)",
        "ix_property_listings_city (btree)",
        "ix_property_listings_price (btree)"
      ]
    },
    {
      "name": "property_types",
      "description": "Типи нерухомості",
      "columns": ["id", "slug", "name", "description"]
    }
  ],
  "enums": {
    "deal_type": ["sale", "rent_long", "rent_short"],
    "listing_status": ["active", "sold", "rented", "removed", "archived"],
    "building_type": ["brick", "panel", "monolith", "gas_block", "wood"],
    "renovation_status": ["cosmetic", "euro", "designer", "none", "construction"],
    "bathroom_type": ["combined", "separate", "multiple"],
    "parking_type": ["ground", "underground", "none", "garage"],
    "heating_type": ["central", "autonomous", "floor", "none"],
    "layout_type": ["studio", "separate", "adjacent"],
    "window_view": ["yard", "street", "park", "water", "forest"],
    "metro_distance_type": ["walk", "transport"]
  },
  "available_filters": [
    "city", "district", "deal_type", "property_type_id",
    "min_price", "max_price", "min_rooms", "max_rooms",
    "listing_status", "min_area", "max_area",
    "has_balcony", "building_type", "renovation_status"
  ],
  "embedding_model": "nomic-embed-text (768d)",
  "language": "ukrainian",
  "sample_queries": [
    "2-кімнатна квартира в центрі до 2 млн грн",
    "Новобудова з балконом біля метро",
    "Будинок з ділянкою в передмісті"
  ]
}
```

## Конфігурація (.env)

```env
# Database (from data_collector docker-compose)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=vmk_data
DB_USER=postgres
DB_PASSWORD=postgres
DB_POOL_MIN=1
DB_POOL_MAX=10

# Ollama (embeddings) — already deployed on 192.168.1.75
OLLAMA_HOST=http://192.168.1.75:11434
OLLAMA_MODEL=nomic-embed-text
OLLAMA_TIMEOUT_SEC=30

# Server
MCP_SERVER_NAME=vmk-data-mcp
MCP_HOST=0.0.0.0
MCP_PORT=3000
MCP_TRANSPORT=streamable-http
MCP_STATELESS_HTTP=true

# Safety
MAX_LIMIT=50
QUERY_TIMEOUT_SEC=10
```

## План реалізації

### Етап 1: Каркас
- [ ] `pyproject.toml` з залежностями: `mcp[cli]`, `asyncpg`, `pgvector`, `pydantic-settings`, `ollama`
- [ ] `config.py` — Pydantic Settings (DB + Ollama + server)
- [ ] `db.py` — asyncpg pool + `register_vector` + read-only guard
- [ ] `embedder.py` — `OllamaEmbedder` з валідацією 768d на старті
- [ ] `main.py` — FastMCP сервер з `streamable-http`

### Етап 2: Інструменти
- [ ] `describe_schema` — статичний опис схеми (хардкод з data_collector моделей)
- [ ] `get_listing_by_id` — SELECT + JOIN property_types, тільки користувацькі поля
- [ ] `search_by_metadata` — FTS через `search_vector` (ukrainian) + фільтри + sort_by + pagination + total_count
- [ ] `search_similar_listings` — query_text → embed → vector <=> + фільтри + pagination

### Етап 3: Доробки
- [ ] Обробка помилок: DB timeout, Ollama недоступний, embedding ≠ 768d
- [ ] Логування: запити, latency, embedding time
- [ ] Graceful shutdown: закриття DB pool

### Етап 4: Інфраструктура
- [ ] `Dockerfile` (multi-stage, uv/uvicorn)
- [ ] `docker-compose.yml` (тільки MCP-сервер, Ollama окремо)
- [ ] `README.md` — підключення до Claude Desktop / Claude Code
- [ ] `.env.example`

## Гібридний пошук (майбутнє)

Інструмент `hybrid_search` (не в MVP):
1. Паралельно виконувати `search_similar_listings` (vector) та `search_by_metadata` (text)
2. Об'єднати результати через Reciprocal Rank Fusion (RRF)
3. Пересортувати та повернути top-N

## Resources (майбутнє)

Відкладено до наступних ітерацій:
- `listing://{id}` — JSON з повним оголошенням
- `schema://property_listings` — опис схеми таблиці
- `stats://database` — агрегати (total_listings, price_range, top_cities)

## Рішення, що прийняті

| Питання | Рішення |
|---------|---------|
| Async vs Sync драйвер | **asyncpg** (async) |
| FTS індекс | Вже є в `data_collector`: колонка `search_vector` + GIN (`ukrainian` конфіг) |
| `describe_schema` | **Статика** (хардкод з моделей data_collector) |
| Resources | **Відкладено** (не в MVP) |
| `get_listing_by_id` | Всі **користувацькі** поля, без службових (`raw_data_id`, `embedding`, `created_at`) |
| Деплой | Docker-контейнер, Ollama вже є на 192.168.1.75 |
| Мова | **Українська**. AI-агент перекладає запити |
