Skip to main content
Glama
otis22

vetmanager-mcp

by otis22
README.md
# vetmanager-mcp

[![Tests](https://github.com/otis22/vetmanager-mcp/actions/workflows/test.yml/badge.svg)](https://github.com/otis22/vetmanager-mcp/actions/workflows/test.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

> **English speakers:** This project is documented in Russian. The API and MCP protocol are language-agnostic and work with any MCP-compatible client. Google Translate handles technical documentation well. See [Quick Start](#быстрый-старт-локально) to get running in 3 commands.

MCP-сервер для интеграции [Vetmanager REST API](https://help.vetmanager.cloud/article/3029) с любым клиентом, поддерживающим [Model Context Protocol](https://modelcontextprotocol.io/). Позволяет управлять операциями ветеринарной клиники через диалоговых AI-агентов на естественном языке.

Сервер берёт на себя bearer-аутентификацию сервиса, хранение Vetmanager credentials на уровне service account, динамическое определение URL API (через `billing-api.vetmanager.cloud`) и форматирование данных. Runtime-контур теперь **bearer-only**: MCP-клиент передаёт только `Authorization: Bearer <service_token>`, а активное Vetmanager-подключение определяется через account context.

Privacy / auth boundary:
- сервис не сохраняет бизнес-данные Vetmanager для постоянного хранения;
- сервис хранит только технические данные integration и metadata сервисных bearer-токенов;
- для режима `login/password -> user token` логин и пароль используются только для token exchange и не сохраняются в storage;
- при смене пароля в Vetmanager сохранённый user token может стать невалидным, и тогда в кабинете потребуется повторная авторизация.

## Требования

- Docker (с плагином Compose)
- `ssh-copy-id` настроен для деплоя на удалённый сервер
- Python на хосте **не требуется** — всё работает через Docker

## Быстрый старт (локально)

```bash
cp .env.example .env          # задайте UID/GID и LOG_LEVEL
docker compose build
docker compose up -d          # запустить MCP-сервер
```

После запуска:

- публичный лендинг доступен на `http://localhost:8000/`
- MCP endpoint остаётся на `http://localhost:8000/mcp`

Полезные runtime-переменные:

- `DATABASE_URL` — строка подключения к БД. По умолчанию используется локальный `sqlite+aiosqlite:///./data/vetmanager.db`.
- `STORAGE_ENCRYPTION_KEY` — ключ шифрования для сохранённых Vetmanager secrets. В production должен быть задан явно.
- `WEB_SESSION_SECRET` — секрет подписи web session cookie для `/register`, `/login`, `/account`. В production должен быть задан явно.
- `WEB_SESSION_MAX_AGE_SECONDS` — срок жизни web session cookie. По умолчанию 86 400 (24 часа).
- `WEB_TRUSTED_PROXY_IPS` — список доверенных reverse proxy IP/host через запятую; только для них сервис учитывает `X-Forwarded-For`.
- `SITE_BASE_URL` — базовый URL self-hosted инсталляции (используется в canonical/og:url лендинга и в mcp.json snippet на странице аккаунта). По умолчанию `https://vetmanager-mcp.vromanichev.ru` (prod). Для self-hosted — задайте свой домен (без trailing slash).
- `SUPPORT_EMAIL` — адрес поддержки. Показывается **только в кабинете** вошедшему пользователю; на публичный лендинг не попадает никогда (этап 284) — иначе адрес собирают спам-боты, парсящие HTML. Не задан или задан с мусором — в кабинете остаётся только ссылка на GitHub Issues. В коде адреса нет: репозиторий открытый, и вписанная в исходник почта осталась бы в истории git навсегда.
- `CUSTOM_CLINIC_HOSTS` — клиники, открывающие Vetmanager на собственном домене, в формате `ключ_аккаунта=хост` через запятую (`clinic=vm.clinic.example`). Ключ аккаунта — тот, по которому клиника заведена в billing-api; из адреса он не выводится. Проверка **добавляется** к публичным зонам `vetmanager.cloud` / `vetmanager2.ru`, а не заменяет их, и привязывает адрес к конкретному аккаунту. Пустая переменная = поведение до этапа 292. Значения задаются только в `.env` на сервере: это чужие адреса, и в открытом репозитории им не место — в коде остаётся имя переменной.
- `ERROR_TRACKING_DSN` — opt-in DSN для отправки unhandled errors в Sentry; пустое значение оставляет tracking выключенным. `SENTRY_DSN` остаётся совместимым alias для запусков вне Compose.
- `ERROR_TRACKING_ENVIRONMENT` — окружение Sentry, по умолчанию `production`; `ERROR_TRACKING_TRACES_SAMPLE_RATE` — доля traces, по умолчанию `0` (exception-only). `ERROR_TRACKING_RELEASE` — необязательный release tag.
- `PERSISTENT_LOG_PATH` — каталог persistent structured application logs; Compose использует named volume. Файл режется по 10 МиБ, хранение — **14 UTC-дней**, объём ограничен отдельным бюджетом в 2 ГиБ. Раньше рядом стоял ещё плоский лимит «не больше 14 файлов», и он бил первым: под нагрузкой файлов за сутки выходит полтора десятка, поэтому две недели молча превращались в один день (этап 302). Срок хранения и бюджет объёма — разные ограничения, и второе не должно подменять первое. Не задавайте путь вне управляемого volume.
- `PORT`, `MCP_PATH`, `LOG_LEVEL` — стандартные настройки MCP HTTP runtime.

Operational helpers:
- `scripts/post_deploy_smoke_checks.sh` — post-deploy checks для `/healthz`,
  `/readyz`, `/metrics` и `/mcp`.
  Поддерживает retry/grace knobs через env:
  `SMOKE_MAX_ATTEMPTS`, `SMOKE_SLEEP_SECONDS`,
  `SMOKE_REQUEST_MAX_TIME_SECONDS` (дедлайн на весь запрос, как `--max-time`).
  При падении deploy script дополнительно печатает `docker compose ps` и tail
  container logs для fast triage.
  Deploy path также принудительно выравнивает `UID`/`GID` для `docker compose`
  и заранее создаёт локальный `data/`, чтобы SQLite storage мог стартовать на
  bind-mounted репозитории без permission drift.

Запуск тестов:

```bash
# default contour: unit + mock + live browser happy-path tests
# Chromium уже предустановлен в test image, доп. setup не нужен
docker compose --profile test run --rm test

# fast contour: без browser и real contour tests
docker compose --profile test run --rm test sh -c "python scripts/run_fast_test_suite.py"

# security contour: только security regressions этапа 44
docker compose --profile test run --rm test sh -c "python -m pytest -m security -q"

# opt-in real contour: real API и real browser tests
docker compose run --rm \
  -e TEST_DOMAIN=<домен> \
  -e TEST_API_KEY=<ключ> \
  test sh -c "python scripts/run_opt_in_real_test_suite.py"

# opt-in real contour с direct real smoke для уже выданного user-token
docker compose run --rm \
  -e TEST_DOMAIN=<домен> \
  -e TEST_USER_TOKEN=<user_token> \
  test sh -c "python scripts/run_opt_in_real_test_suite.py"

# opt-in real contour с login/password -> user token exchange
docker compose run --rm \
  -e TEST_DOMAIN=<домен> \
  -e TEST_USER_TOKEN_BASE_URL=<https://clinic.vetmanager2.ru> \
  -e TEST_USER_LOGIN=<login> \
  -e TEST_USER_PASSWORD=<password> \
  test sh -c "python scripts/run_opt_in_real_test_suite.py"
```

Тестовые контуры:
- `fast`:
  быстрый inner-loop без Playwright/browser и без real API/browser tests.
- `default`:
  unit + mock/e2e + live localhost browser tests, без реального Vetmanager API.
- `opt_in_real`:
  real API e2e + real browser tests; real browser flow дополнительно требует
  `RUN_REAL_BROWSER_TESTS=1`.

Safe workflow для real/browser verification:
- использовать только env-driven `TEST_*` и `RUN_REAL_BROWSER_TESTS=1`, не
  записывать clinic credentials в репозиторий;
- для `login/password -> user token` real flow сервис повторяет production
  контракт Vetmanager:
  `POST /token_auth.php` c `app_name=vetmanager-mcp`, затем
  `X-USER-TOKEN` + `X-APP-NAME` на последующих API-запросах;
- для production browser checks использовать временный account и удалять его
  после ручной верификации, чтобы не копить тестовые сущности в production.

Что входит в default `docker compose --profile test run --rm test`:
- unit tests;
- mock/e2e tests;
- live localhost browser tests через Playwright;
- browser happy-path tests для обоих web auth flows;
- cleanup regression для browser-created account data;
- zero-warning quality gate через warning-as-error launcher.

## Observability

HTTP probes и scrape endpoints:
- `GET /healthz` — process liveness, не ходит во внешние зависимости.
- `GET /readyz` — readiness probe; сейчас проверяет storage через `SELECT 1`.
- `GET /metrics` — Prometheus-compatible text exposition для process-local service metrics.

Что собирается из коробки:
- structured logs c `request_id`, `correlation_id`, `event_category`, `event_name`;
- `runtime` / `audit` / `security` logger taxonomy;
- service metrics:
  - `vetmanager_http_requests_total` + `vetmanager_http_request_latency_seconds_{count,sum,max}` — inbound HTTP web routes;
  - `vetmanager_auth_failures_total{source,reason}` — bearer/web auth failures grouped by reason;
  - `vetmanager_upstream_failures_total{target,reason}` — failures to VM/billing upstream (timeout / network_error / http_5xx / circuit_open);
  - `vetmanager_upstream_requests_total{target,status}` + `vetmanager_upstream_request_latency_seconds_{count,sum,max}` — all VM API requests with their outcome (stage 88);
  - `vetmanager_tool_calls_total{endpoint,method,outcome}` + `vetmanager_tool_call_latency_seconds_{count,sum,max}` — per-tool (endpoint+method) latency and success/error rate via crud_helpers instrumentation (stage 88);
  - `vetmanager_report_ai_jobs_total{outcome}`, `vetmanager_report_ai_job_transitions_total{from_stage,to_stage}`, `vetmanager_report_ai_job_terminal_outcomes_total{outcome}` и `vetmanager_report_ai_job_{stage_,}duration_seconds_{count,sum,max}` — process-local наблюдение создания и lifecycle Report AI. Эти series считают локальные наблюдения, а не уникальные задания: `abandoned_wait` означает отсутствие следующего MCP poll до local TTL, не состояние очереди Ветменеджера; если polling возобновится после TTL, то же задание начнёт новое observation и может дать второй sample;
  - `vetmanager_report_ai_exports_total{operation,outcome}` + `vetmanager_report_ai_export_duration_seconds_{count,sum,max}` — отдельно наблюдаемые start/poll export attempts; duration начинается только после успешного `StartReport` и не содержит URLs, IDs или clinic data;
  - `vetmanager_cache_{hits,misses,invalidations,evictions}_total` + `vetmanager_cache_entries`;
  - `vetmanager_business_events_total{event=...}` — fixed allowlist business events from `service_metrics._ALLOWED_BUSINESS_EVENTS`, including account/token/OAuth lifecycle events and token-expiry warning events;
  - `vetmanager_token_preset_issued_total{preset}` — issuance counter by access preset;
  - `vetmanager_activation_funnel_accounts{stage}` — aggregate activation funnel gauges (`registered`, `connected`, `with_active_tokens`, `ready_for_mcp`, `with_recent_usage_7d`) without account/email/domain labels; `ready_for_mcp` means an active Vetmanager connection and a live token, while `with_active_tokens` can expose stale-token/no-connection gaps; `with_recent_usage_7d` is a freshness signal and is stricter than the account UI checklist, which marks a usable token as connected after any successful historical request;
  - `vetmanager_account_last_request_age_hours{account_id}` — hours since the
    last successful bearer runtime request for active accounts with active
    connection and live token; never-used tokens use the earliest live token
    creation time as the age anchor;
  - `vetmanager_rate_limit_backend_degraded_total{reason}` — Redis rate-limit backend fallback/strict failure counter;
  - `vetmanager_sanitizer_failures_total` — depersonalized response sanitizer failures;
  - `/metrics` endpoint gated by optional `METRICS_AUTH_TOKEN` env (stage 111.1): when set, requires `Authorization: Bearer <token>` or returns 403;
  - activation telemetry scan on `/metrics` runs only when `METRICS_AUTH_TOKEN` is configured and the request passed bearer auth;
  - invalid `/metrics` bearer attempts increment `vetmanager_auth_failures_total{source="metrics",reason="invalid_token"}` and emit a `security` log event `metrics_auth_failed`;
- opt-in Sentry bootstrap для unhandled exceptions.

### Exceptions raised by VM tools

Все клиенты MCP tools ловят исключения наследники `VetmanagerError`:
- `AuthError` — 401/403 от Vetmanager (неверный токен/доступ запрещён);
- `NotFoundError` — 404 (ресурс не существует);
- `VetmanagerTimeoutError` — истёк timeout upstream запроса;
- `VetmanagerUpstreamUnavailable` (stage 91) — circuit breaker OPEN для domain, fast-fail без вызова upstream. Наследует `VetmanagerError`, поэтому existing `except VetmanagerError:` ловят его без изменений;
- `RateLimitError` — срабатывание локального rate limiter'а;
- `HostResolutionError` — сбой резолва VM-хоста через billing API;
- `VetmanagerError` — база для upstream 5xx и протокольных ошибок.

Error tracking:
- без `ERROR_TRACKING_DSN`/`SENTRY_DSN` integration не активируется;
- pattern-based sanitizer (stage 89) редактирует заголовки/cookies/query/body по substring'ам `token|key|secret|auth|api|cookie|bearer|password|credential|session|csrf|signature|jwt|hmac|otp|passphrase`, с whitelist для observability headers (`x-request-id`, `x-correlation-id`, `x-api-version` и т.д.).

Отдельный runbook по эксплуатации observability-контура:
- `artifacts/observability-runbook-vetmanager-mcp-ru.md`

## Bearer-only runtime

Рабочий MCP runtime больше не принимает `X-VM-Domain` и `X-VM-Api-Key` в HTTP-заголовках. Все tools и prompts работают только через `Authorization: Bearer <service_token>`.

Bearer-токен привязан к account сервиса:

- `service_bearer_token` идентифицирует account;
- account хранит ровно одно активное `vetmanager_connection`;
- активное connection поддерживает auth mode `domain + rest_api_key` и `login/password -> user token`;
- домен и Vetmanager API key хранятся в storage-слое и не передаются в MCP tool arguments.

### Текущий статус provisioning

Пользовательский web-контур уже начал работать:

- лендинг перепозиционирован под ветврачей, администраторов и руководителей клиник;
- регистрация вынесена в главный CTA главной страницы;
- доступны лендинг, регистрация account и login/logout;
- доступна страница `/account`;
- доступна настройка активной Vetmanager integration через wizard:
  сначала выбор способа авторизации, затем только релевантные поля;
- доступна настройка `domain + rest_api_key`;
- доступна настройка user-token integration через `domain + login/password -> user token`;
- логин и пароль Vetmanager не сохраняются и не отображаются повторно после submit;
- для token exchange используется `POST /token_auth.php` с `multipart/form-data`
  и фиксированным `app_name=vetmanager-mcp`, без `X-REST-API-KEY`;
- для нового account кабинет показывает onboarding state с явным следующим шагом;
- state-changing web forms защищены signed CSRF token layer;
- `/register`, `/login` и bearer runtime защищены shared rate limiting:
  in-memory по умолчанию, Redis-backed при `REDIS_URL`;
- HTML responses отдают baseline security headers: `CSP`, `X-Frame-Options`, `Referrer-Policy`, `X-Content-Type-Options`;
- кабинет показывает health активной integration и статус `reauth_required`, если сохранённый user token больше не проходит валидацию;
- доступен выпуск Bearer-токенов с именем, сроком действия, preset'ом доступа
  (`full_access`, `read_only`, `frontdesk`, `doctor`, `finance`, `inventory`, `report_ai`)
  и опциональным режимом деперсонализации ответов; `report_ai` отображается в UI
  как `Analytics` и даёт full read-only доступ плюс права сохранения Report AI отчётов;
- web-выпуск безопасен по умолчанию: blank expiry становится 30 days,
  default preset — `report_ai` / `Analytics`, чтобы новые токены могли работать
  с Report AI; `full_access` и `*.*.*.*` IP mask требуют
  явного подтверждения в форме;
- после выпуска raw bearer token показывается в отдельной success-card в верхней части страницы и может быть скопирован кнопкой;
- доступен список токенов со статусом, сроком действия, `last_used_at`, `request_count` и revoke action.

На текущем этапе в репозитории уже есть:

- storage foundation и миграции для `accounts`, `vetmanager_connections`, `service_bearer_tokens`;
- шифрование Vetmanager credentials;
- hash-only хранение bearer-токенов;
- сервис сохранения Vetmanager connection `domain + rest_api_key`;
- web exchange `login/password -> user token` c сохранением только полученного user token;
- web auth для account через email/password и signed cookie session;
- account onboarding wizard с выбором `API key` или `login/password`;
- signed CSRF layer для `/register`, `/login`, `/logout` и `/account/*`;
- rate limiting для `/register`, `/login` и bearer runtime через общий backend;
- baseline security headers для HTML-ответов web UI;
- web-экран сохранения active Vetmanager integration;
- web-выпуск Bearer-токенов с preset-based scopes и one-time показом raw значения;
- централизованная деперсонализация ответов для токенов с включённым флагом:
  structured PII поля маскируются, free-text scrub ограничен whitelist clinical
  fields, а при ошибке sanitizer'а raw payload не возвращается;
- success-card для нового raw bearer token с copy action;
- список Bearer-токенов с usage metadata;
- runtime usage accounting (`last_used_at`, `request_count`);
- безопасный audit log для create/revoke Bearer-токенов.

То есть runtime-контракт уже bearer-only, а account provisioning, Vetmanager integration, token management, security baseline web-контура и продуктовый landing больше не internal-only.

## Подключение Cursor

### Шаг 1 — запустить сервер

```bash
cp .env.example .env          # задайте UID/GID и LOG_LEVEL при необходимости
docker compose up -d mcp
```

### Шаг 1.1 — создать web account

- `http://localhost:8000/register` — регистрация account
- `http://localhost:8000/login` — вход в account
- `http://localhost:8000/account` — кабинет после входа, включая Vetmanager integration, выпуск Bearer-токенов и их список

### Шаг 2 — настроить `~/.cursor/mcp.json`

```json
{
  "mcpServers": {
    "vetmanager": {
      "url": "http://localhost:8000/mcp",
      "headers": {
        "Authorization": "Bearer vm_st_your_service_token"
      }
    }
  }
}
```

- `Authorization` должен содержать service bearer token, выданный для account сервиса.
- `domain` и `api_key` больше не указываются в `mcp.json` и не передаются в tool arguments.

### Политика credentials

| Контекст | Где берутся credentials |
|----------|------------------------|
| Cursor / Claude | `~/.cursor/mcp.json` → `Authorization: Bearer <service_token>` |
| Vetmanager `domain` / `api_key` | активное `vetmanager_connection` выбранного account |
| Явный аргумент инструмента | не поддерживается |
| e2e real tests (api_key) | `TEST_DOMAIN` / `TEST_API_KEY` в `.env` или CI secrets |
| e2e real tests (user_token) | `TEST_USER_TOKEN` или `TEST_USER_TOKEN_BASE_URL` + `TEST_USER_LOGIN` + `TEST_USER_PASSWORD` |
| Проектный `.env` (runtime) | используется только для infra-конфига (`DATABASE_URL`, `STORAGE_ENCRYPTION_KEY`, transport settings) |

#### Тестовый контур: какой стенд считается нашим

**Наш тестовый стенд один — `devtr6`.** Решение владельца 02.09.2026: держать
два контура значит проверять не то. На `devtr6` проходят живые пробы, включая
запись медкарты, и именно он записан выделенным тестовым стендом с этапа 5.
`TEST_DOMAIN` / `TEST_API_KEY` указывают на него.

**Расхождение, которое пока сохраняется.** Bearer-токен тестового MCP-клиента
(тот, которым ходят инструменты из Cursor или Claude) указывает на **другой**
стенд — `devslon67` (`devslon67.vetmanager2.ru`). Сборки разные: одна и та же
запись медкарты проходит на `devtr6` и падает с 500 на `devslon67`. Перевод
токена требует действия на стороне Ветменеджера — контур за токеном выдаём
не мы (этап 255.4).

Отсюда правило до перевода: **фраза «проверил на devtr6» неполна**. Указывать,
чем именно проверял:

| Чем проверяю | Какой стенд отвечает |
|---|---|
| сырой REST с `TEST_API_KEY` | `devtr6` |
| MCP-инструмент под bearer-токеном | `devslon67` |

Проверить, где находишься, можно по числу клиник: на `devtr6` их две, под
bearer-токеном — одна. Обнаружилось это неприятным способом: медкарта с `id = 6`
на двух стендах разная, и проверка едва не пошла по чужим данным.

### Production notes

- Rate limiting по умолчанию in-memory и process-local. Для multi-worker /
  multi-instance production задайте `REDIS_URL`, чтобы web и bearer limit state
  использовали общий backend.
- Redis backend задаёт bounded connect/socket/operation timeouts. При временной
  недоступности Redis default policy fail-open деградирует в process-local
  fallback и увеличивает `vetmanager_rate_limit_backend_degraded_total{reason}`;
  `RATE_LIMIT_REQUIRE_REDIS=1` делает init/runtime failures fail-closed.
- Текущий CSRF/session hardening рассчитан на single-instance deployment с
  общим `WEB_SESSION_SECRET`; при горизонтальном масштабировании нужен единый
  secret и согласованный deployment policy.
- Для production рекомендуется:
  - явный `WEB_SESSION_SECRET`;
  - явный `STORAGE_ENCRYPTION_KEY`;
  - раздельное хранение этих двух секретов;
  - `WEB_TRUSTED_PROXY_IPS=<ip1,ip2>` только если сервис реально стоит за
    доверенным reverse proxy;
  - `WEB_ENABLE_HSTS=1` за HTTPS reverse proxy;
  - внешний rate limit на `/register` и `/login`.
- Для error tracking в production:
  - задавать только production DSN;
  - держать `ERROR_TRACKING_TRACES_SAMPLE_RATE=0` или низкое значение, если
    нужен только exception tracking;
  - проверять, что reverse proxy не добавляет в headers чувствительные данные,
    которые не нужны приложению.
- Billing-resolved Vetmanager host теперь принимается только как bare HTTPS
  origin: без `userinfo`, custom port и path/query/fragment.
- Отдельные deployment notes по security baseline этапа 44:
  `artifacts/security-deployment-notes-vetmanager-mcp-ru.md`.
- Observability runbook этапа 45:
  `artifacts/observability-runbook-vetmanager-mcp-ru.md`.
- Operations readiness baseline этапа 47:
  `artifacts/operations-readiness-vetmanager-mcp-ru.md`.
- Release checklist этапа 47:
  `artifacts/release-checklist-vetmanager-mcp-ru.md`.

### Тест подключения

После запуска сервера и настройки `mcp.json` в чате Cursor попросите:

> «Покажи список клиентов клиники» — инструмент `get_clients` вызовется с bearer-derived account context.

## Деплой на сервер

Предусловие: `ssh-copy-id user@host` выполнен.

Прод-хост проекта: `<your-domain>` (например, `mcp.example.com`).

```bash
# Первичная настройка (один раз)
./scripts/init_server.sh user@host

# Обновление кода и перезапуск
export FEEDBACK_FINGERPRINT_PEPPER="<stored-production-pepper>"
./scripts/deploy_server.sh user@host
```

По умолчанию код размещается в `/opt/vetmanager-mcp`. Альтернативный путь:

```bash
./scripts/init_server.sh user@host /srv/vetmanager-mcp
export FEEDBACK_FINGERPRINT_PEPPER="<stored-production-pepper>"
./scripts/deploy_server.sh user@host /srv/vetmanager-mcp
```

Используйте тот же сохранённый `FEEDBACK_FINGERPRINT_PEPPER`, что и для
автоматического deploy; не генерируйте новое значение для каждого запуска.

### Режим для приватного репозитория: rsync + deploy

Если сервер не может делать `git clone/pull` (приватный repo), используйте синхронизацию кода по SSH:

```bash
export FEEDBACK_FINGERPRINT_PEPPER="$(openssl rand -hex 32)"  # один раз для production, хранить как secret
./scripts/sync_and_deploy_server.sh root@<your-server-ip> /opt/vetmanager-mcp
```

`FEEDBACK_FINGERPRINT_PEPPER` нужно сгенерировать один раз, сохранить постоянно
на уровне `STORAGE_ENCRYPTION_KEY` и переиспользовать во всех последующих
deploy. Не регенерируйте его перед каждым запуском: смена значения ломает
сопоставление уже сохранённых feedback fingerprints без отдельной миграции.

Скрипт:
- синхронизирует проект через `rsync` (без `.git`, `.env`, служебных директорий);
- запускает `deploy_server.sh` с `SKIP_GIT_PULL=1`;
- выполняет те же smoke-check и TLS-check, что обычный deploy.

### Post-deploy smoke

Локально или на сервере можно прогнать:

```bash
./scripts/post_deploy_smoke_checks.sh
./scripts/post_deploy_smoke_checks.sh http://127.0.0.1:8000 <your-domain>
```

Если `/metrics` защищён `METRICS_AUTH_TOKEN`, smoke script автоматически
передаёт `Authorization: Bearer $METRICS_AUTH_TOKEN`.

### Полностью автоматический деплой после push в main

Добавлен workflow: `.github/workflows/deploy-prod.yml`.

Он срабатывает после успешного workflow `Tests` для ветки `main` и делает:
- `rsync` кода на прод-сервер;
- запуск `deploy_server.sh` в режиме `SKIP_GIT_PULL=1`.

Нужные GitHub Secrets:
- `PROD_SSH_TARGET` (пример: `root@<your-server-ip>`)
- `PROD_SSH_PRIVATE_KEY` (приватный ключ для SSH)
- `FEEDBACK_FINGERPRINT_PEPPER` (обязателен для production/PostgreSQL feedback fingerprints)
- `FEEDBACK_CONTACT_EMAIL` (необязательный контакт, показывается только в форме жалобы кабинета)
- `PROD_REMOTE_DIR` (опционально, по умолчанию `/opt/vetmanager-mcp`)
- `PROD_SSL_DOMAIN` (опционально, ваш домен)
- `PROD_CERTBOT_EMAIL` (опционально, email для certbot)

`FEEDBACK_FINGERPRINT_PEPPER` храните как долгоживущий production secret: он
должен оставаться тем же между deploy и не ротироваться без плана миграции
исторических feedback fingerprints.

### TLS (Let's Encrypt) и автообновление

`init_server.sh` настраивает `nginx` reverse proxy, а `deploy_server.sh` автоматически вызывает проверку сертификата:
- если сертификата нет — будет первичный выпуск;
- если до истечения осталось меньше 30 дней — будет выполнено продление;
- после обновления сертификата `nginx` перезагружается автоматически.

При необходимости можно переопределить домен и email для certbot:

```bash
SSL_DOMAIN=mcp.example.com CERTBOT_EMAIL=ops@example.com \
./scripts/init_server.sh root@<your-server-ip>

SSL_DOMAIN=mcp.example.com CERTBOT_EMAIL=ops@example.com \
./scripts/deploy_server.sh root@<your-server-ip>
```

Обязательные внешние условия:
- DNS A-record вашего домена должен указывать на IP сервера;
- на сервере/в облачном firewall должны быть открыты порты `80/tcp` и `443/tcp`.

### Прод-конфиг Cursor MCP (локально, не в репозиторий)

Добавьте отдельный сервер в локальный `~/.cursor/mcp.json`.

**Только прод:**

```json
{
  "mcpServers": {
    "vetmanager-prod": {
      "url": "https://<your-domain>/mcp",
      "headers": {
        "Authorization": "Bearer vm_st_prod_service_token"
      }
    }
  }
}
```

**Оба сервера (локальный + прод) одновременно:**

```json
{
  "mcpServers": {
    "vetmanager-local": {
      "url": "http://localhost:8000/mcp",
      "headers": {
        "Authorization": "Bearer vm_st_local_service_token"
      }
    },
    "vetmanager-prod": {
      "url": "https://<your-domain>/mcp",
      "headers": {
        "Authorization": "Bearer vm_st_prod_service_token"
      }
    }
  }
}
```

Можно подключить разные account/token пары к разным серверам или использовать разные bearer-токены для разных записей в `mcpServers`.

Bearer-токен должен храниться только в локальном `mcp.json` пользователя или в секретах CI, но не в репозитории. Vetmanager `domain` и `api_key` должны храниться только внутри storage-слоя сервиса в зашифрованном виде.

## MCP-инструменты

Инструменты работают по bearer-only контракту: runtime credentials берутся только из `Authorization: Bearer <service_token>`, а Vetmanager credentials резолвятся через account context. Параметры `limit` (1–100) и `offset` (0–10 000) защищены от случайных массовых выборок.

`get_daily_schedule` поддерживает постраничное чтение насыщенного дневного
расписания: если ответ содержит `has_more=true`, следующий вызов нужно делать с
`offset=next_offset`. Поля `data.admission` и `data.totalCount` сохранены для
совместимости. Если `truncated=true`, но `has_more=false`, значит данных больше,
но безопасного следующего `offset` нет: либо достигнута граница offset
(`pagination_limit_reached=true`), либо upstream вернул пустую страницу без
продвижения (`pagination_stalled=true`). В таком случае нужно сузить дату/врача/
клинику, а не повторять тот же offset.

### Контракт `tools/list`

MCP-клиенты могут использовать `tools/list` как источник истины по возможностям сервера.
Для каждого зарегистрированного инструмента сервер публикует:

- `name`
- `description`
- `inputSchema`

`description` формируется из актуальных docstrings инструментов и не должен
содержать runtime credentials. Начиная с этапа 18 descriptions также включают
доменные синонимы из справочника сущностей Vetmanager, чтобы LLM лучше
сопоставлял пользовательские формулировки вроде `хозяин`, `запись на приём`,
`приходная накладная`, `остаток на складе` с правильными MCP-инструментами.
`inputSchema` отражает реальные типы аргументов и ограничения вроде `limit: 1..100`.

### Agent feedback

Инструмент `report_problem` позволяет LLM-агенту сообщить о подозрительной ошибке,
нехватке инструмента/параметра, плохом description или несовпадении документации с
реальным поведением. Он доступен для активных bearer-токенов без отдельного
business-scope, но не принимает runtime credentials и не должен получать raw
Vetmanager payload, bearer token, API key, пароли или персональные данные.

Агент должен вызывать `report_problem` не только при явной ошибке, но и
`even when the tool call succeeded`, если результат не позволяет качественно
ответить пользователю: `empty result but relevant records were expected`,
`response is missing fields needed to answer`, `tool description/docs promised or implied a capability that the result does not provide`,
`missing tool, parameter, filter, sort, pagination, or date semantics blocks a reasonable request`,
`workaround was necessary because no direct tool or parameter exists`,
или `successful response is suspicious, inconsistent, or not enough to answer`.
Do not call report_problem for legitimately empty results. Do not paste raw tool response bodies, raw record IDs, user's verbatim message, or full error payloads.

Feedback хранится в БД в `agent_feedback_reports` после redaction/truncation.
Агентам нужно описывать форму проблемы, а не данные: вместо ФИО клиента,
телефона и адреса использовать `<client>`, `<owner>`, `<phone>`, `<address>`.
Кличку питомца и личное имя, наоборот, оставляют как есть — решение этапа 290:
они никого не опознают, а без них отчёт нельзя воспроизвести. После этапа 150 отчёты получают `possible_pii` flag:
старые ручные/model reports помечаются консервативно, новые reports получают
флаг при privacy-like redaction или placeholders; auto-events остаются
`possible_pii=false`, потому что не сохраняют raw error text.
Проверенные workaround-и хранятся отдельно в `known_issues`; только они могут
вернуться агенту как deterministic playbook. Runtime не вызывает LLM и не
делает автоисправления кода. Offline triage выполняется через
`scripts/triage_agent_feedback.py`.

Known-issue bootstrap and write-path diagnostic:

```bash
# Run after DB migrations are applied; the script expects known_issues and
# known_issue_match_events tables to exist.
python scripts/seed_known_issues.py --dry-run
# If only created>0 and updated=0, this creates missing issues without
# overwriting existing ones. Review every would_update=slug:field,field line.
python scripts/seed_known_issues.py --apply

# Existing issues are never overwritten by the plain --apply command. If an
# update is intentional, first make a full database backup (the
# show-known-issue-config command does not include every seed-managed field).
# Then add --allow-update with the exact slug and field set from dry-run.

# Production-safe diagnostic: pass real non-secret DB ids from an active
# account/token. Do not pass bearer token strings or Vetmanager credentials.
python scripts/seed_known_issues.py diagnostic-auto-event --apply \
  --account-id <account_id> --bearer-token-id <bearer_token_id>
```

The seed exits nonzero and rolls back the entire transaction when any changed
issue lacks an exact `--allow-update` approval. A fresh run recomputes the
field list; if it changed since the dry-run, review it again before approving.
The argument format is `--allow-update slug:field,field`; copy each entire
`would_update=` value exactly, including field order, and repeat the argument
for each issue whose fields are to be changed.
The output lists field names only, never their values. On production, if the
post-deploy dry-run still reports KI-45 as `updated`, stop and inspect its full
configuration before any apply.

Diagnostic `--apply` requires `FEEDBACK_FINGERPRINT_PEPPER`; without it the
script fails closed. `diagnostic-auto-event` requires an explicit `--apply` or
`--dry-run`; a dry-run without identity reports `status=skipped`, not a
successful write-path validation. `--apply` prevalidates that supplied DB ids
exist and are active before it writes synthetic diagnostic rows. Before/after
production runs, check runtime logs for `feedback_auto_event_failed`,
`known_issue_lookup_failed` and `known_issue_match_event_write_failed`.

For aggregate-only effectiveness diagnostics without raw report text:

```bash
python scripts/triage_agent_feedback.py match-effectiveness --days 30
```

The output shows report counts with/without linked known issues, match events
by source (`report`, `auto`, `injection`) and readiness counts for known issues
whose agent injection would be skipped because a valid playbook is missing.
It intentionally omits report summaries, details and known-issue titles.

Each diagnostic `--apply` intentionally writes one synthetic auto-event/report
with `related_tool='__stage157_diagnostic__'` to prove the write path. Clean it
up after production verification if those rows should not remain in analytics:

```sql
BEGIN;
DELETE FROM known_issue_match_events
WHERE related_tool = '__stage157_diagnostic__';
DELETE FROM agent_feedback_reports
WHERE source = 'auto' AND related_tool = '__stage157_diagnostic__';
DELETE FROM known_issues
WHERE related_tool = '__stage157_diagnostic__'
  AND title LIKE '[seed:stage157-diagnostic] %';
COMMIT;
```

Для production/PostgreSQL обязателен `FEEDBACK_FINGERPRINT_PEPPER`: он нужен,
чтобы fingerprints ошибок хранились как non-reversible HMAC. Операторы должны
запускать `python scripts/triage_agent_feedback.py retention-cleanup --days 180`
не реже одного раза в месяц после triage; активные `new/grouped` reports
сохраняются до ручного разбора.

### Универсальные sort/filter для list GET

Во всех list `get_*` инструментах поддерживаются дополнительные параметры:

- `sort`: массив объектов `{"property":"<field>","direction":"ASC|DESC"}`
- `filter`: массив объектов `{"property":"<field>","value":<value>,"operator":"<op>"}`

Поддерживаемые операторы фильтра:

- `=`, `!=`, `<>`
- `<`, `<=`, `>`, `>=`
- `in`, `not in` (`value` должен быть массивом)
- `like`

Пример:

```json
{
  "limit": 20,
  "offset": 0,
  "sort": [{"property": "id", "direction": "DESC"}],
  "filter": [{"property": "id", "value": 10, "operator": ">="}]
}
```

### Глобальные уведомления `messages/*`

Добавлены операционные инструменты для внутренних уведомлений Vetmanager:

- `send_message_to_all(message, campaign)`
- `send_message_to_users(message, campaign, user_ids)`
- `send_message_to_roles(message, campaign, roles)`
- `get_message_reports(limit, offset, campaign="", sort=None, filter=None)`

`get_message_reports` поддерживает обычный list-контракт `limit/offset/sort/filter`
и дополнительный query-параметр `campaign`, потому что этот сценарий нужен для
получения статуса конкретной рассылки.

### Кеширование GET-запросов

- Обычные успешные GET-запросы к Vetmanager API кешируются in-memory на **15 минут**. Подтверждение статуса параметра продажи и проверка результата используют свежий GET без кеша.
- Ключ кеша: `METHOD + canonical_full_url_with_sorted_query + api_key_hash + account_id`.
- `api_key_hash` — отпечаток (`sha256`) API-ключа, изолирует кеш между разными ключами.
- `account_id` — добавлен с этапа 54.2.3 для строгой изоляции между аккаунтами
  даже при случайном совпадении api_key. Fallback на `none` для legacy caller'ов
  без account-контекста.
- Кеш-tag: `domain:entity`, где `entity` берётся из пути `/rest/api/<entity>/...`.
- После успешного `POST`/`PUT`/`DELETE` кеш для соответствующего тега `domain:entity` инвалидируется.
- Ограничение подхода: кеш живёт только в памяти процесса и полностью сбрасывается при рестарте сервера.

**126 инструментов** по 15 группам сущностей:

| Группа | Инструменты | Кол-во |
|--------|-------------|--------|
| Client | `get_clients`, `get_debtors`, `get_client_by_id`, `get_personal_account_link_by_phone`, `create_client`, `update_client`, `delete_client`, `get_client_profile`, `get_inactive_clients` | 9 |
| Pet | `get_pets`, `get_pet_by_id`, `create_pet`, `update_pet`, `delete_pet`, `get_pet_profile`, `get_inactive_pets` | 7 |
| Admission | `get_admissions`, `get_admission_by_id`, `create_admission`, `update_admission`, `get_client_upcoming_visits`, `get_daily_schedule` | 6 |
| MedicalCard | `get_medical_cards`, `get_medical_cards_by_date`, `get_medical_cards_by_client_id`, `get_medical_card_by_id`, `create_medical_card`, `update_medical_card`, `get_vaccinations` | 7 |
| Invoice | `get_invoices`, `get_average_invoice`, `get_invoice_by_id`, `update_invoice` | 4 |
| Finance | `get_payments`, `get_payment_by_id`, `get_client_payment_applications`, `get_revenue_summary`, `get_invoice_documents`, `get_invoice_documents_by_period`, `get_invoice_document_by_id`, `get_closing_of_invoices`, `get_closing_of_invoice_by_id`, `get_cassas`, `get_cassa_by_id`, `get_cassa_closes`, `get_cassa_close_by_id` | 13 |
| Good | `get_goods`, `get_good_by_id`, `search_invoice_goods`, `get_good_combination`, `calculate_good_combination_price`, `create_good`, `update_good` | 7 |
| User | `get_users`, `get_user_by_id`, `update_user` | 3 |
| Warehouse | `get_good_groups`, `get_good_group_by_id`, `get_good_sale_params`, `get_good_sale_param_by_id`, `get_party_accounts`, `get_party_account_by_id`, `get_party_account_docs`, `get_party_account_doc_by_id`, `get_store_documents`, `get_store_document_by_id`, `get_suppliers`, `get_supplier_by_id`, `create_supplier`, `update_supplier`, `get_good_stock_balance`, `update_good_sale_price`, `set_good_sale_param_status` | 17 |
| Clinical | `get_hospitalizations`, `get_hospitalization_by_id`, `create_hospitalization`, `update_hospitalization`, `get_hospital_blocks`, `get_hospital_block_by_id`, `get_diagnoses` | 7 |
| Reference | `get_breeds`, `get_breed_by_id`, `get_pet_types`, `get_pet_type_by_id`, `get_cities`, `get_city_by_id`, `get_city_types`, `get_streets`, `get_street_by_id`, `get_units`, `get_unit_by_id`, `get_roles`, `get_role_by_id`, `get_user_positions`, `get_user_position_by_id`, `get_combo_manual_names`, `get_combo_manual_name_by_id`, `get_combo_manual_items`, `get_combo_manual_item_by_id` | 19 |
| Operations | `get_clinics`, `get_clinic_by_id`, `get_timesheets`, `get_timesheet_by_id`, `get_timesheet_types`, `create_timesheet`, `update_timesheet`, `delete_timesheet`, `get_properties`, `get_anonymous_clients`, `send_message_to_all`, `send_message_to_users`, `send_message_to_roles`, `get_message_reports` | 14 |
| Schedule | `get_doctor_free_slots` | 1 |
| Report AI | `get_report_ai_prompt_helper`, `create_report_ai_job`, `get_report_ai_job`, `confirm_report_ai_job_candidate`, `reject_report_ai_job_candidate`, `get_report_ai_job_data`, `start_report_export`, `get_report_export_download`, `get_report_ai_job_export`, `save_report_ai_job_as_report` | 10 |
| Feedback | `report_problem` | 1 |

Payment REST API доступен только на чтение: Vetmanager Payment entity разрешает `restList`/`restView`, поэтому MCP не публикует `create_payment`. В реальном Payment REST нет прямых `client_id`/`pet_id`; для клиентского или pet-scoped контекста оплат используйте `get_client_payment_applications`, который читает применения оплат через `closingOfInvoices`.

### Ограничения CRUD по API

Некоторые операции запрещены контроллерами Vetmanager REST API:

| Сущность | Запрещено | Причина |
|----------|-----------|---------|
| Admission | DELETE | явно запрещён в filterRestAccessRules |
| MedicalCards | DELETE | явно запрещён в filterRestAccessRules |
| Payment | CREATE, UPDATE, DELETE | только restList + restView |
| Hospital | CREATE, DELETE | явно запрещены |
| User | CREATE, DELETE | явно запрещены |
| Suppliers | DELETE | не в whitelist |
| Cassa, PartyAccount, StoreDocument, Properties, HospitalBlock | CUD | только чтение |

Полная матрица: `artifacts/api_crud_permissions-ru.md`.

## MCP Prompts

**20 готовых шаблонов** для типовых сценариев — LLM использует их для составления цепочек вызовов инструментов:

Prompts работают по тому же bearer-only контракту, что и tools:
они принимают только бизнес-параметры сценария. Runtime credentials не
передаются в prompt arguments и не должны прокидываться в tool calls.

| Категория | Промпты |
|-----------|---------|
| Администратор | `daily_schedule`, `find_client`, `client_balance`, `book_appointment`, `doctor_workload`, `unconfirmed_appointments` |
| Врач | `pet_history`, `last_vaccinations`, `add_medical_note`, `current_inpatients`, `pet_invoices`, `pet_full_profile` |
| Финансы | `daily_revenue`, `unpaid_invoices`, `popular_services` |
| Склад и клиентская база | `search_good`, `low_stock`, `new_clients`, `client_no_visit` |
| Аналитика | `report_ai_prompt_helper` |

### Report AI для ad-hoc отчётов

Когда пользователю нужен отчёт, выборка, группировка, тренд или бизнес-условие, которое проще получить через конструктор отчётов Vetmanager, агент должен сначала вызвать tool `get_report_ai_prompt_helper` или prompt `report_ai_prompt_helper`, затем сформировать русский `intent_text` и создать async job через `create_report_ai_job`. Если пользователь уже дал готовый русский `intent_text`, helper не является обязательным preflight.

Базовый flow:

1. `get_report_ai_prompt_helper` или `report_ai_prompt_helper` — получить правила формулировки intent и ограничения.
2. `create_report_ai_job(intent_text)` — создать Report AI job.
3. `get_report_ai_job(job_id)` — poll не более шести раз за диалог; затем сообщить человеку `job_id` для поздней проверки. `QUEUE_TIMEOUT` — завершённый отказ после часа в очереди, `LLM_UNAVAILABLE` — таймаут провайдера: подождать минимум пять минут и один раз повторить тот же intent.
4. Если статус `needs_confirmation`, подтвердить только кандидата с теми же фильтрами, периодом и группировкой. Если подходящего нет, вызвать `reject_report_ai_job_candidate` один раз и опрашивать тот же `job_id` до `ready_to_save` или `failed`; сразу после отказа статус может ещё быть `needs_confirmation`. После timeout или 409 не повторять POST автоматически.
5. Если статус `existing_report_matched`, сразу вызвать `get_report_ai_job_data`.
6. Если статус `ready_to_save` и нужны строки, явно вызвать `save_report_ai_job_as_report` с вменяемым названием отчёта; это write-tool, отчёт станет видимым в Vetmanager. Не обещать срок на save: API не возвращает expiry или retry metadata.
7. `get_report_ai_job_data(job_id)` — получить `columns`, `rows`, `total`, `limited` и, когда upstream отдаёт, `csv_export_url`.

`intent_text` для `create_report_ai_job` ограничен Vetmanager лимитом 20000 символов. Для сложных отчётов лучше не просто писать длинный текст, а явно указать период, фильтры, метрики, группировки и сортировку. `recognized.preview_example_row` в статусе job — это LLM-generated пример ожидаемой строки, а не подтверждённая строка из данных клиники.

`save_report_ai_job_as_report` нельзя прятать внутри read-only сценария: пользователь или вызывающий агент должен понимать, что создаётся persistent report. Для этого есть preset `Analytics` (`report_ai`): full read-only scopes + `report_ai.write`, без общего Full access. Для названий использовать короткие осмысленные заголовки с вопросом, периодом и MCP-origin, например `MCP debtors by negative balance 2026-06-15`.

`get_report_ai_job_data` отдаёт до **10 000 строк**. `limited=true` означает обрезку ответа; ровно 1000 строк больше не считаются доказательством обрезки. Для больших результатов можно сузить период или взять CSV export:

1. `start_report_export(report_id, filter_json=None)` — запуск `/rest/api/report/StartReport`; использовать если пользователь дал `report_id`, явно просит CSV/XLSX или данные пришли с `limited=true` и `report_id` доступен.
2. `get_report_export_download(report_file_id)` — follow-up после `start_report_export`; MCP сам скачивает CSV, чистит его теми же слоями защиты персональных данных, что и строки отчёта, кладёт у себя и возвращает ссылку на свой домен: `download_url`, `expires_at`, `rows`, `columns`. Адреса файлов на стороне Ветменеджера наружу не отдаются. Если генерация ещё идёт, повторить вызов после задержки с bounded retry.
3. `get_report_ai_job_export(job_id, filter_json=None)` — convenience export только для `saved`/`existing_report_matched` jobs с `job.report_id`; не сохраняет `ready_to_save` jobs автоматически.

Ограничения export flow: список отчётов по REST не опубликован, поэтому `list_reports` tool нет; export работает только для отчётов с включённым REST access (`allow_rest_api=1`). `REPORT_NOT_ALLOWED_FOR_REST` означает постоянный отказ; `CONSTRUCTOR_BUSY` и `RUN_RATE_LIMITED` — временный отказ с `retry_after_seconds`, после которого допустима одна новая попытка. Для старых текстовых отказов без кода задержка 30 минут. После успешного запуска опрашивать только тот же `report_file_id`: `FILE_BUILD_NOT_STARTED` и `FILE_NOT_READY` требуют подождать обычно 5 секунд, максимум 12 опросов или минуту; `FILE_BUILD_FAILED` завершает ожидание.

Про саму ссылку: она публичная — кто её получил, тот скачает файл, поэтому она непредсказуема (два сегмента по 128 бит на HMAC с серверным секретом), живёт трое суток и перестаёт работать, как только отозван доступ, которым выгрузка сделана. Файл отдаётся вложением, не индексируется и не кэшируется. Отдаётся только CSV (UTF-8 с BOM, разделитель `;`) — XLSX собрать без новой зависимости нельзя. Предел размера выгрузки — 25 МБ.

## Product metrics (ad-hoc report)

Для быстрого ответа на «сколько живых аккаунтов, кто мёртв, сколько токенов выдано, какие failures» — `/product-metrics` skill + `scripts/product_metrics_report.py`. Запускается on-demand через SSH → `docker compose exec mcp`.

Примеры:
```
/product-metrics                              # default 30-day window, top-10
/product-metrics --top-n=5                    # top-5 tables
/product-metrics --format=json                # машинно-читаемый
```

Метрики (не покидают prod-сервер при обычном просмотре; email маскируются `al***@ex***.com` — маскирование снижает случайный disclosure, но это не анонимизация: в небольшой customer base и при characteristic доменах результат остаётся частично re-identifiable. Отчёт предназначен для owner-local просмотра):
- **Accounts**: total / new 24h-7d-30d / live (req within 7d) / dead (reg>30d, 0 req in 30d) / no tokens / no active connection + dead-accounts table.
- **Activation funnel**: connected / with tokens / with active tokens / with recent usage (7d) / ready for MCP / needs connection / needs token / needs client use.
- **Tokens**: active / expiring in 7d / issued 24h / revoked 24h-7d.
- **Requests**: total 24h-7d-30d + top-N accounts by 30d request count.
- **Failures** (24h / 7d / 30d breakdown): rate_limited, revoked, expired, ip_denied, no_scopes, no_connection.
- **Feedback**: reports 24h-7d-30d, new/possible-PII 30d, breakdowns by source/status/severity/category, top related tools, known-issue match events 7d-30d and top known issues by 30d match count. Feedback section outputs aggregates only; raw report text, account ids and bearer token ids are not included.

Параллельно в Prometheus-expose'е `/metrics` копится `vetmanager_business_events_total{event=...}` counter для будущих Grafana-дашбордов без изменения кода.

## Prometheus / Grafana

Production compose поднимает Prometheus и Grafana в профиле `production`:

- Prometheus: `127.0.0.1:${PROMETHEUS_PORT:-9090}`.
- Grafana: `127.0.0.1:${GRAFANA_PORT:-3000}`.
- Prometheus scrapes `mcp:8000/metrics` внутри Docker network. Production
  deploy требует `METRICS_AUTH_TOKEN`; Prometheus получает его через
  bearer-token file, токен не инлайнится в config.
- Grafana provisioning добавляет datasource `Prometheus` и dashboard
  `Vetmanager MCP Overview`.

Доступ с рабочей машины — SSH-туннель (`-N` — только форвардинг, без shell):

```bash
ssh -N -L 3000:127.0.0.1:3000 -L 9090:127.0.0.1:9090 root@<your-server-ip>
```

После этого Grafana доступна на `http://127.0.0.1:3000`, Prometheus — на
`http://127.0.0.1:9090`. Если порт занят (`Address already in use`) — туннель
уже поднят, проверить: `pgrep -af "ssh.*<your-server-ip>"`.

Логин в Grafana — учётка админа из server `.env`:

```bash
ssh root@<your-server-ip> 'cd /opt/vetmanager-mcp && grep -E "^(GRAFANA_ADMIN_USER|GRAFANA_ADMIN_PASSWORD)=" .env'
```

`GRAFANA_ADMIN_USER` в `.env` может отсутствовать — тогда действует дефолт
`admin` (см. `docker-compose.yml`).

Публично Grafana не публикуется по умолчанию. Если нужен доступ через nginx,
сначала включить basic auth на nginx location и только потом проксировать
Grafana. Без basic auth наружу не открывать.

Перед production deploy задать `METRICS_AUTH_TOKEN` и `GRAFANA_ADMIN_PASSWORD`
в server `.env`; пустые значения и defaults вроде `admin`/`password` deploy
script отвергает. Anonymous access и sign-up в Grafana выключены.

Dashboard queries используют только низкокардинальные service labels
(`tool`, `endpoint`, `outcome`, `target`, `status`, `event`) и не должны
добавлять email, clinic/customer identifiers, token prefixes или
payload-derived labels.

## CI/CD

| Воркфлоу | Триггер | Что делает |
|----------|---------|------------|
| `test.yml` | push / pull request → main | два обязательных contour job: `fast` и `default` |
| `test-real.yml` | вручную (`workflow_dispatch`) | `opt_in_real` contour: real API e2e и opt-in real browser tests |
| `deploy-prod.yml` | автоматически после успешного `test.yml` на main | rsync кода на сервер, docker build, restart, smoke checks |

Добавить secrets для real tests:
- `VETMANAGER_TEST_API_KEY`
- опционально `VETMANAGER_TEST_USER_TOKEN`
- либо `VETMANAGER_TEST_USER_LOGIN` и `VETMANAGER_TEST_USER_PASSWORD`
- для real browser tests дополнительно включать `RUN_REAL_BROWSER_TESTS=1`

## Артефакты

| Путь | Назначение |
|------|------------|
| `artifacts/prd-vetmanager-mcp-ru.md` | Требования к продукту: видение, цели, персоны, функциональные и нефункциональные требования |
| `artifacts/technical-requirements-vetmanager-mcp-ru.md` | Технические требования: архитектура, стек, структура проекта |
| `artifacts/api_entity_reference-ru.md` | Справочник по сущностям Vetmanager API (38 сущностей) |
| `artifacts/api_crud_permissions-ru.md` | Матрица разрешённых CRUD-операций по Vetmanager REST API |
| `artifacts/api-research-notes-ru.md` | Накопленные неочевидные знания об API (чеклист полей, filter operators, edge cases) — читать перед work with admission/pet/medical_card/timesheet |
| `artifacts/vetmanager_openapi_v6.json` | Спецификация OpenAPI v6 для Vetmanager REST API |
| `artifacts/vetmanager_postman_collection.json` | Коллекция Postman для ручного тестирования |
| `artifacts/review/*.md` | Периодические deep-review (super-review) отчёты с findings + Codex arbitration |
| `artifacts/security-deployment-notes-vetmanager-mcp-ru.md` | Чек-лист для production deploy'я (если присутствует) |
| `artifacts/observability-runbook-vetmanager-mcp-ru.md` | Runbook для операций с метриками и логами (если присутствует) |
| `artifacts/operations-readiness-vetmanager-mcp-ru.md` | Go/No-Go чеклист готовности к прод (если присутствует) |
| `artifacts/release-checklist-vetmanager-mcp-ru.md` | Регламент релиза (если присутствует) |

## Self-hosted / Развернуть у себя

Этот проект — **open-source**. Вы можете развернуть собственный экземпляр MCP-сервера для вашей клиники или организации.

Что нужно:
1. Сервер с Docker (любой VPS/dedicated)
2. Домен с DNS A-record на IP сервера
3. SSH-доступ к серверу

```bash
git clone https://github.com/otis22/vetmanager-mcp.git
cd vetmanager-mcp
./scripts/init_server.sh root@<your-server-ip>
./scripts/deploy_server.sh root@<your-server-ip>
```

Скрипт `init_server.sh` автоматически настроит Docker, PostgreSQL, Nginx reverse proxy и TLS-сертификат через Let's Encrypt. Подробнее — в секции [Деплой на сервер](#деплой-на-сервер).

## Contributing

Проект открыт для контрибуций. Если вы нашли баг или хотите предложить улучшение:

- [Открыть issue](https://github.com/otis22/vetmanager-mcp/issues)
- [Security-уязвимости](SECURITY.md) — сообщайте приватно

Перед отправкой PR убедитесь, что тесты проходят: `docker compose --profile test run --rm test`.

## License

[MIT](LICENSE)