incident-mcp
by DyakonovAlex
README.md
# incident-mcp
Инцидентный MCP-сервер для агента дежурного инженера (on-call). FastMCP
(Python 3.12, uv), транспорт stdio, единый asyncpg-пул на процесс.
Сервер читает локальный инцидентный стенд из `homework-stand/`: Postgres
с логами, деплоями и реестром инцидентов плюс payments-api под нагрузкой.
В стенде есть намеренный дефект — деградация latency учебного эндпоинта;
ищется он по данным через tools сервера, а не чтением исходников
(см. «Разбор инцидента» ниже).
## Состав
```
├── src/incident_mcp/ MCP-сервер
│ ├── app.py инстанс FastMCP, lifespan (пул), инструкции агента
│ ├── db.py доступ к БД: пул, fetch/execute, ToolError-обёртки
│ ├── schemas.py схемы аргументов: enum-типы, парсер длительностей
│ ├── tools_read.py read-tools (без побочных эффектов)
│ ├── tools_write.py write-tools (меняют состояние в Postgres)
│ └── server.py stdio entry point (в stdout только MCP-сообщения)
├── tests/ unit + интеграционные (на живом стенде)
├── homework-stand/ стенд: docker compose (Postgres, payments-api, simulator)
└── memory/ проектная память агентов (brief, decisions, progress)
```
## Обоснование состава tools
Каждый tool — один шаг разбора инцидента; универсального `query(sql)` нет
намеренно, агент не видит SQL и не может обойти доменные ограничения.
| tool | шаг разбора | почему отдельным tool'ом |
|---|---|---|
| `incidents_search` | найти открытый инцидент | входная точка сценария; фильтры severity/status/time_range |
| `incident_get` | карточка одного инцидента | сводка разбора по id; неизвестный id — ошибка со списком доступных |
| `deploys_recent` | сопоставить деградацию с релизом | рост latency сразу после деплоя — главный подозреваемый |
| `logs_query` | WARN/ERROR вокруг начала деградации | сообщения — пользовательские данные (в стенде есть prompt-injection строка; tool отдаёт её как данные) |
| `metrics_latency` | форма деградации latency | без него агент увидит только точку, а не кривую; возвращает готовый агрегат (корзины, avg, p95, hit-rate), а не сырые строки |
| `runbook_get` | типовые симптомы и шаги диагностики | читается перед началом разбора |
| `service_catalog_get` | кому эскалировать | команда, on-call, зависимости |
| `incident_acknowledge` | взять инцидент в работу | write-операция, отдельный tool, в description явно написано «WRITE-ОПЕРАЦИЯ» |
| `incident_create_summary` | зафиксировать выводы разбора | write-операция, отдельный tool |
Описания tools важнее обычного: в OpenCode они попадают в один список со
встроенными (bash, чтение файлов). Если из description не понятно, когда
брать `metrics_latency` вместо bash/psql, агент возьмёт bash. Поэтому у
каждого tool есть title и description, отвечающие на три вопроса: что
делает, когда применять, какие ограничения и side effects. Аргументы
валидируются inputSchema (enum-типы `Literal`, `Field(ge=..., le=...)`,
парсер длительностей 1m..7d); невалидный ввод возвращает структурированную
ошибку `isError` с поправимым текстом, сервер не падает.
## Быстрый старт
```bash
# 1. Стенд
cd homework-stand
cp .env.example .env
docker compose up -d --build
docker compose run --rm simulator # ~5 минут: засев истории + живой трафик
# 2. MCP-сервер (из корня)
cp .env.example .env
uv sync
uv run incident-mcp # stdio-сервер
```
## Проверка через MCP Inspector
Inspector (v2) работает в скриптовом CLI-режиме: цель (команда сервера)
до `--`, опции после.
```bash
# lifecycle: initialize (DSN сервер возьмёт из .env сам)
npx @modelcontextprotocol/inspector --cli uv run incident-mcp -- \
--method initialize --format json \
--cwd "$(pwd)" -e FASTMCP_CHECK_FOR_UPDATES=off
# tools/list — 9 tools с title/description/inputSchema
npx @modelcontextprotocol/inspector --cli uv run incident-mcp -- \
--method tools/list --format json \
--cwd "$(pwd)" -e FASTMCP_CHECK_FOR_UPDATES=off
# вызов tool
npx @modelcontextprotocol/inspector --cli uv run incident-mcp -- \
--method tools/call --tool-name metrics_latency --format json \
--tool-args-json '{"endpoint":"/api/v1/orders/{order_id}/price","time_range":"1h","bucket":"1m"}' \
--cwd "$(pwd)" -e FASTMCP_CHECK_FOR_UPDATES=off
# невалидные аргументы -> isError:true, сервер жив
npx @modelcontextprotocol/inspector --cli uv run incident-mcp -- \
--method tools/call --tool-name metrics_latency --format json \
--tool-args-json '{"endpoint":"/api/v1/orders/{order_id}/price","bucket":"xyz"}' \
--cwd "$(pwd)" -e FASTMCP_CHECK_FOR_UPDATES=off
```
Прогон всех 9 tools и четырёх невалидных вызовов (полный лог —
`docs/inspector-checks.txt`) дал: initialize → serverInfo
`incident-mcp 3.4.7`, protocolVersion `2025-11-25`; tools/list → 9 tools;
каждый tool вернул данные; невалидные аргументы →
`{"isError":true}` с текстом («Invalid time range 'xyz'...», «Incident
'INC-999' not found. Known incident ids: ...», pydantic-ошибка
`limit: Input should be greater than or equal to 1`).
## Подключение к OpenCode
`opencode.json` (ключевая часть):
```json
{
"mcp": {
"incident-mcp": {
"type": "local",
"command": ["uv", "run", "incident-mcp"],
"cwd": "/абсолютный/путь/к/incident-mcp",
"enabled": true,
"timeout": 30000,
"environment": {
"FASTMCP_CHECK_FOR_UPDATES": "off"
}
}
}
}
```
Нюансы, проверенные на практике:
- DSN в конфиге не дублируется: `STAND_DATABASE_URL` берётся только из
`.env`. Сервер ищет `.env` по абсолютному пути от файла модуля
(`src/incident_mcp/server.py` → корень проекта), поэтому загрузка не
зависит от cwd, с которого opencode запускает процесс. Переменная из
окружения имеет приоритет (`load_dotenv` не перезаписывает существующие).
- `cwd` — абсолютный: относительный opencode резолвит от корня workspace
и процесс не находит проект.
- `FASTMCP_CHECK_FOR_UPDATES` — строка `"off"`: в FastMCP 3.x поле
`check_for_updates` это `Literal["stable","prerelease","off"]`, значение
`false` роняет процесс pydantic-ошибкой при импорте, до MCP-handshake.
- После подключения tools сервера видны агенту с префиксом
`incident-mcp_*` (incidents_search, metrics_latency, ...).
## Проверка кода
```bash
uv run pytest # 31 тест; интеграционные скипаются без стенда (порт 5433)
uv run ruff format src tests # 9 files left unchanged
uv run ruff check src tests # All checks passed!
uv run mypy src # Success: no issues found in 7 source files
```
## Лог реального диалога (ReAct-сценарий)
Сценарий пройден агентом (OpenCode + этот MCP-сервер) на живом стенде,
прогон симулятора 2026-08-12 18:32–18:37. Цепочка вызовов:
1. `incidents_search(service="payments", status="open", time_range="1h")`
→ `INC-001`, severity high, открыт 18:31:41, «Рост времени ответа
/api/v1/orders/{order_id}/price».
2. `runbook_get(service="payments")` → план: кривая latency, контрастный
эндпоинт, деплои, логи кэша.
3. `metrics_latency(endpoint="/api/v1/orders/{order_id}/price",
time_range="1h", bucket="1m")` → форма деградации: до 18:32 фоновый
трафик avg 3–8 мс при hit-rate ~100%; с 18:32 монотонный рост —
avg 39.2 → 80.0 → 139.2 → 198.5 → 265.1 → 336.9 мс,
p95 51.5 → 457.5 мс, cache_hit_pct = 0.0 во всех корзинах.
4. `metrics_latency(endpoint="/api/v1/catalog/items", ...)` → здоровый
эндпоинт ровен: avg 0.4–0.7 мс. Проблема локальна для расчёта цены.
5. `deploys_recent(service="payments")` → v1.5.0 «refactor: unified
response cache» (i.petrov), задеплоен 18:29:41 — за 2 минуты до
открытия инцидента и за 3 минуты до начала деградации.
6. `logs_query(service="payments-api", time_range="1h", level="WARN")` →
«response cache grew to 5000...9495 entries, hit_rate=0.0»: кэш
наполняется, но попаданий ноль. `level="ERROR"` → пусто.
Гипотеза (со ссылками на данные): деплой v1.5.0 сломал кэш ответов —
записи создаются, но поиск никогда не находит их (hit_rate=0.0 при
растущем числе записей); значит, ключ записи не совпадает с ключом
поиска, скорее всего в ключ попадает поле, уникальное для каждого
запроса. Каждый запрос идёт по медленному пути расчёта цены, и под
нагрузкой latency монотонно растёт.
Далее write-tools: `incident_acknowledge("INC-001")` →
`{"status":"acknowledged","changed":true}`;
`incident_create_summary("INC-001", ...)` → сводка сохранена в
`incidents.summary`.
Подтверждение по исходникам (после гипотезы): `api/cache.py`,
`build_key` включал `request_id` — уникальный для каждого запроса,
поэтому каждый lookup был промахом. Фикс: `request_id` из ключа убран
(ключ = эндпоинт + параметры).
## Разбор инцидента INC-001
- Симптом: монотонный рост latency `/api/v1/orders/{order_id}/price`
под нагрузкой 20 rps при ровном `/api/v1/catalog/items`.
- Триггер: деплой payments v1.5.0 «refactor: unified response cache»
(18:29:41), деградация с 18:32.
- Корень: в ключе кэша ответов был `request_id` — кэш наполнялся,
но не отдавал ни одного ответа (hit_rate 0.0), каждый запрос шёл
по медленному пути (агрегация по журналу price_events).
- Фикс: `request_id` исключён из `build_key` (homework-stand/api/cache.py,
main.py), образ api пересобран (`docker compose up -d --build api` —
заодно сбрасывает in-memory кэш для честного сравнения).
## До/после: два реальных прогона
Оба прогона — симулятор стенда: 20 rps × 300 c = 6000 запросов,
замер — `metrics_latency` (1m-корзины) и `/internal/cache-stats`.
До фикса (сломанный кэш, прогон 18:32–18:37):
| минута | req | avg_ms | p95_ms | hit% |
|---|---|---|---|---|
| 18:32 | 280 | 39.2 | 51.5 | 0.0 |
| 18:33 | 959 | 80.0 | 113.8 | 0.0 |
| 18:34 | 959 | 139.2 | 178.6 | 0.0 |
| 18:35 | 951 | 198.5 | 243.7 | 0.0 |
| 18:36 | 966 | 265.1 | 323.7 | 0.0 |
| 18:37 | 685 | 336.9 | 457.5 | 0.0 |
После фикса (прогон 18:41–18:46):
| минута | req | avg_ms | p95_ms | hit% |
|---|---|---|---|---|
| 18:41 | 342 | 20.8 | 54.4 | 39.2 |
| 18:42 | 960 | 13.4 | 54.8 | 71.0 |
| 18:43 | 960 | 10.6 | 56.8 | 81.7 |
| 18:44 | 959 | 20.0 | 73.5 | 71.0 |
| 18:45 | 962 | 25.8 | 88.9 | 69.1 |
| 18:46 | 617 | 39.3 | 105.4 | 59.6 |
Итог:
| показатель | до | после | изменение |
|---|---|---|---|
| cache hit-rate | 0.0% все минуты | 39–82% (69% за прогон; 3314 hits / 1486 misses) | поднялся с 0% |
| avg latency, пик | 336.9 мс | 39.3 мс (минимум 10.6) | в 8.6 раза ниже |
| p95 latency, пик | 457.5 мс | 105.4 мс | в 4.3 раза ниже |
Небольшой подъём avg в конце прогона «после» — рост медленного пути
вместе с журналом price_events (357 тыс. строк к концу прогона) на
фоне TTL-промахов; кэш при этом продолжает работать (hit-rate > 0).
Подробности про стенд — в `homework-stand/README.md`.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing