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
python scripts/seed_known_issues.py --apply

# 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>
```

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 минут**.
- Ключ кеша: `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` инвалидируется.
- Ограничение подхода: кеш живёт только в памяти процесса и полностью сбрасывается при рестарте сервера.

**124 инструмента** по 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` | 16 |
| 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`, `get_report_ai_job_data`, `start_report_export`, `get_report_export_download`, `get_report_ai_job_export`, `save_report_ai_job_as_report` | 9 |
| 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 с bounded retry; статусы `queued`/`recognizing`/`building_preview` означают ожидание, `failed` возвращается пользователю как ошибка источника.
4. Если статус `needs_confirmation`, показать `job.candidates`, выбрать только `report_id` из этого списка и вызвать `confirm_report_ai_job_candidate`. Обработать это в текущем workflow: API не возвращает срок действия или retry metadata.
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` отдаёт не более **1000 строк**, и признака обрезки в ответе нет. В апстриме два разных предела: `AiReportRenderer::VIEW_ROW_LIMIT = 1000` режет SQL отчёта и возвращает `total` от уже обрезанного набора, а `JobService::DATA_ROW_LIMIT = 10000` — предел выдачи, по которому считается `limited`. Поэтому `limited = (total > 10000)` для данных ИИ-отчёта **структурно всегда `false`**: доверять ему как признаку полноты нельзя. Ровно 1000 строк означают, что отчёт почти наверняка обрезан — MCP добавляет к такому ответу подсказку `report_ai_probable_truncation`. Экспорт идёт другим путём (`AiReportRenderer::getStatement()` предела рендера не применяет), поэтому полные данные берутся именно через CSV/XLSX export:

1. `start_report_export(report_id, filter_json=None)` — запуск `/rest/api/report/StartReport`; использовать если пользователь дал `report_id`, явно просит CSV/XLSX или данные пришли ровно на пределе в 1000 строк и `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`). Новый upstream включает REST access для AI reports, но старые/частично обновлённые контуры могут возвращать отказ. `StartReport` также может вернуть 403 `Report creating in progress` или `can not run a report more than 10 minutes`: это временный guard для REST export всего tenant, а не гарантия о конкретном `report_id`; API не возвращает `retry_after`. Для этих двух распознанных guard подождать 30 минут и выполнить одну новую попытку `StartReport`; не повторять автоматически, немедленно или параллельно. После успешного запуска poll только его `report_file_id` через `get_report_export_download`.

Про саму ссылку: она публичная — кто её получил, тот скачает файл, поэтому она непредсказуема (два сегмента по 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)