"""MCP-сервер панели: любые ИИ-агенты читают состояние серверов по /mcp.
Транспорт — streamable HTTP (stateless + JSON-ответы), приложение
монтируется в FastAPI в app.main на /mcp. Клиент Claude Code:
claude mcp add --transport http ghard https://panel.example.com/mcp
Инструкции агенту (AGENT_INSTRUCTIONS) отдаются и как instructions
инициализации, и как prompt agent_guide — паттерн reference-реализации
gnexus-creds (data_api). Все тулы read-only: агент — наблюдатель.
"""
import json
from datetime import datetime, timedelta, timezone
from fastapi import Request
from starlette.responses import Response
from mcp.server.fastmcp import FastMCP
from app.api.events import list_events as _list_events
from app.api.servers import get_server, list_servers
from app.api.services import list_services as _list_services
from app.api.services import service_incidents as _service_incidents
from app.api.shares import list_shares as _list_shares
from app.db import get_db
AGENT_INSTRUCTIONS = """\
# GHard Monitor — инструкция для ИИ-агента
## Назначение
Этот сервер — MCP-интерфейс панели мониторинга GHard Monitor
(Gnexus Hardware Monitor). Ты — наблюдатель-диагност: отвечаешь
пользователю о состоянии его серверов и сервисов. Все инструменты —
**только чтение**: менять настройки, удалять и перезапускать ты не можешь
(и это хорошо — панельные изменения делает человек через веб-интерфейс).
## Данные
- **Серверы** — машины с установленным агентом hard-monitor: CPU/RAM/swap,
load, диски по маунтам, сеть (счёт с агента), процессы, docker-контейнеры,
апптайм, заметка пользователя.
- **Сервисы** — health-чеки внешних HTTP-эндпоинтов (gnexus-сервисы:
/health и похожие). Панель пробует их по расписанию и хранит историю
откликов + журнал моментов недоступности.
- **Сетевые хранилища** — примонтированные NAS-шары и их заполнение.
- **Журнал событий** — всё, что панель зафиксировала: offline/пороги/
контейнеры/сервисы (хранение 90 дней).
## Язык
Отвечай пользователю на его языке. Вывод тулов технический (статусы
и события — английские слаги), переводи их в человеческую речь сам.
## Карта инструментов — что вызывать
| Вопрос пользователя | Инструмент |
|---|---|
| «как мои серверы?» / «что вообще происходит?» | `panel_overview` — одним вызовом: все серверы + сервисы + хранилища |
| «что с web-01?» / «что жрёт память?» / «почему тормозит?» | `server_details` (id — из `panel_overview`) |
| «растёт ли диск на db-01 за сутки?» / тренд | `server_history` |
| «сервис падал?», «с какими сервисами проблемы?» | `services_overview`, потом `service_incidents` (id — из overview) |
| «что происходило ночью / за последние часы?» | `events_recent` |
Порядок для составных вопросов: обзор → выбранная детализация. `panel_overview`
дешёвый — начинай с него, если не уверен.
## Словарь статусов
- Сервер: `online` (пакеты идут) / `offline` (панель не получает метрики —
агент умер, сеть или машина выключена) / данных нет.
- Сервис: `up` (здоров) / `degraded` (работает, но сообщает о деградации) /
`down` (не отвечает) / `pending` (данных ещё нет). Поле `mode`
(maintenance / readonly / draining) — это **режим работы**, а не падение;
не пугай пользователя режимом.
- Журнал: severity `info` / `warning` / `critical`.
## Словарь событий (поле type в журнале)
- `server_offline` / `server_online` — сервер перестал/возобновил слать метрики.
- `cpu_high` / `ram_high` / `swap_high` / `disk_high` / `load_high` — ресурс
превысил порог (порог и значение — в payload); `*_recovered` — вернулся
в норму (гистерезис: открытие и закрытие — разные пороги).
- `container_added` / `container_removed` / `container_started` /
`container_exited` — docker-контейнер появился/исчез/запустился/завершился
(в payload: name, image, exit_code если есть).
- `service_down` / `service_degraded` / `service_recovered` — сервис
перестал отвечать / деградировал / восстановился.
- Пороги сжимаются в человеческую речь, например:
`cpu 95% (≥90%)`, `disk / 98% (≥90%)`, `load1 2.1×cores (≥2.0×)`.
## Правила
- ID серверов и сервисов бери из обзорных тулов — угадывание запрещено.
- `server_history(hours)`: разумно 1 (детали), 6, 24 (сутки) или 168 (неделя;
глубже недели данных нет — история метрик хранится 7 дней).
- Числа передавай как есть из тулов (проценты, МБ/с, ms) — не пересчитывай
единицы самостоятельно.
- Если сервер offline — не паникуй и не повторяй вопросы тулами; скажи
последнее известное состояние (в `server_details`) и время последнего пакета.
- Пользователь просит «что-нибудь сделать» (перезапустить, удалить) —
объясни, что доступ только на чтение, изменения — через веб-интерфейс.
## Подключение (для администратора панели)
```
claude mcp add --transport http ghard https://panel.example.com/mcp
```
Transport — streamable HTTP. Авторизация: если у панели задан
`GHARD_ADMIN_TOKEN`, добавь заголовок
`Authorization: Bearer <GHARD_ADMIN_TOKEN>` в конфигурацию MCP-клиента.
"""
mcp = FastMCP(
"GHard Monitor",
instructions=AGENT_INSTRUCTIONS,
stateless_http=True, # без сессий — работает за reverse proxy
json_response=True,
streamable_http_path="/",
)
@mcp.prompt()
def agent_guide() -> str:
"""📘 Гайд: как отвечать на вопросы о серверах и сервисах через GHard Monitor."""
return AGENT_INSTRUCTIONS
def _dumps(data) -> str:
return json.dumps(data, ensure_ascii=False, indent=1)
def _worst_disk(disks: list[dict] | None) -> dict | None:
if not disks:
return None
return max(disks, key=lambda d: d.get("percent", 0))
@mcp.tool()
async def panel_overview() -> str:
"""Обзор всего: серверы (статус, CPU/RAM/диски, сеть, последний пакет),
сервисы (health), сетевые хранилища. Самый быстрый способ ответить на
«как мои серверы?» / «что вообще происходит?»."""
servers = await list_servers()
online = sum(1 for s in servers if s["status"] == "online")
lines = [f"Серверов: {len(servers)}, онлайн: {online}, не в сети: {len(servers) - online}"]
for s in servers:
m = s.get("metrics")
if m:
worst = _worst_disk(m.get("disks"))
disk = f", диск {worst['mount']} {worst['percent']:.0f}%" if worst else ""
lines.append(
f"#{s['id']} {s['name']} [{s['status']}] CPU {m['cpu']:.0f}%, "
f"RAM {m['ram']['used'] / 2**30:.1f}/{m['ram']['total'] / 2**30:.1f} GiB{disk}, "
f"сеть {m['net_in_mbs']:.2f}/{m['net_out_mbs']:.2f} MB/s, "
f"пакет {s['last_seen']}"
)
else:
status = s['status']
lines.append(f"#{s['id']} {s['name']} [{status}] — данных ещё нет")
services = await _list_services()
if services:
bad = sum(1 for x in services if x["status"] in ("down", "degraded"))
lines.append(f"Сервисов: {len(services)}, проблемных (down/degraded): {bad}")
for svc in services:
report = svc.get("report") or {}
identity = ""
if report.get("version"):
identity = f", {report.get('service') or '?'} {report['version']}"
mode = f", режим {report['mode']}" if report.get("mode") else ""
msg = f" ({svc['message']})" if svc.get("message") else ""
lines.append(
f"#{svc['id']} {svc['name']} [{svc['status']}] {svc['latency_ms'] or '—'} ms{identity}{mode}{msg}"
)
shares = await _list_shares()
if shares:
lines.append(f"Сетевые хранилища: {len(shares)}")
for sh in shares:
if sh["total"]:
percent = sh["used"] / sh["total"] * 100
lines.append(
f"#{sh['id']} {sh['name']} [{sh['status']}] "
f"{sh['used'] / 2**30:.1f}/{sh['total'] / 2**30:.1f} GiB ({percent:.0f}%), {sh['path']}"
)
else:
lines.append(f"#{sh['id']} {sh['name']} [{sh['status']}] — нет данных, {sh['path']}")
return "\n".join(lines)
@mcp.tool()
async def server_details(server_id: int) -> str:
"""Полное текущее состояние одного сервера: метрики, диски, топ процессов,
docker-контейнеры, заметка. id бери из panel_overview."""
try:
server = await get_server(server_id)
except Exception:
return f"Сервер #{server_id} не найден"
return _dumps(server)
@mcp.tool()
async def server_history(server_id: int, hours: float = 1.0, max_points: int = 200) -> str:
"""История метрик сервера за `hours` часов, прореженная до `max_points`.
Возвращает точки: ts, cpu, ram%, swap, load, сеть MB/s, диски.
История метрик хранится 7 дней — глубже точки удаляются.
"""
since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
db = get_db()
cursor = await db.execute(
"""SELECT ts, cpu, load1, load5, load15, ram_used, ram_total,
swap_used, swap_total, uptime, net_in_mbs, net_out_mbs, disks_json
FROM metrics WHERE server_id = ? AND ts >= ?
ORDER BY ts DESC LIMIT 5000""",
(server_id, since),
)
rows = await cursor.fetchall()
points = [
{
"ts": row["ts"],
"cpu": row["cpu"],
"load": [row["load1"], row["load5"], row["load15"]],
"ram_percent": round(row["ram_used"] / row["ram_total"] * 100, 1)
if row["ram_total"]
else None,
"swap_percent": round(row["swap_used"] / row["swap_total"] * 100, 1)
if row["swap_total"]
else None,
"uptime": row["uptime"],
"net_in_mbs": row["net_in_mbs"],
"net_out_mbs": row["net_out_mbs"],
"disks": json.loads(row["disks_json"]),
}
for row in reversed(rows)
]
if len(points) > max_points:
step = len(points) / max_points
points = [points[int(i * step)] for i in range(max_points)]
return _dumps({"server_id": server_id, "hours": hours, "points": points})
# --- Сервисы (health-чеки) -----------------------------------------------------
@mcp.tool()
async def services_overview() -> str:
"""Все наблюдаемые сервисы (health-чеки): state, latency, HTTP-код,
self-report (версия/окружение/режим). id нужен для service_incidents."""
services = await _list_services()
lines = [f"Сервисов: {len(services)}"]
for svc in services:
report = svc.get("report") or {}
identity = []
for key in ("service", "version", "environment"):
if report.get(key):
identity.append(str(report[key]))
id_str = f" — {' '.join(identity)}" if identity else ""
mode = f", режим {report['mode']}" if report.get("mode") else ""
code = f" код {svc['code']}" if svc.get("code") is not None else ""
msg = f" ({svc['message']})" if svc.get("message") else ""
lines.append(
f"#{svc['id']} {svc['name']} [{svc['status']}] "
f"{svc['latency_ms'] or '—'} ms{code}{mode}{msg}{id_str}"
)
return "\n".join(lines)
@mcp.tool()
async def service_incidents(service_id: int, limit: int = 20) -> str:
"""Журнал моментов недоступности сервиса: периоды down+degraded.
Возвращает: start, end (null = идёт сейчас), duration_s,
worst (худшее состояние в периоде), code, message (причина из первой точки).
"""
try:
incidents = await _service_incidents(service_id, limit=limit)
except Exception:
return f"Сервис #{service_id} не найден"
return _dumps(incidents)
# --- Журнал событий ------------------------------------------------------------
@mcp.tool()
async def events_recent(limit: int = 30, severity: str = "") -> str:
"""Хвост единого журнала событий (90 дней хранения).
severity — необязательный фильтр: info | warning | critical.
Возвращает: id, ts, type, severity, message, data (payload события).
"""
if limit < 1 or limit > 1000:
return "limit: 1..1000"
events = await _list_events(limit=min(limit, 1000))
if severity:
events = [e for e in events if e.get("severity") == severity]
return _dumps(events)
mcp_app = mcp.streamable_http_app()
# ASGI-эндпоинт MCP: регистрируется прямо в FastAPI на /mcp (см. app.main),
# потому что Mount() не совпадает с путём без хвостового слэша, а lifespan
# смонтированного sub-app не запускается сам — менеджер сессий стартует
# в lifespan app.main (mcp.session_manager.run()).
mcp_asgi = mcp_app.router.routes[0].app
async def mcp_endpoint(request: Request) -> "Response":
"""Мост FastAPI → ASGI streamable-http-app.
FastAPI валидирует сигнатуру endpoint'а: голый ASGI-объект (scope,
receive, send) был бы прочитан как query-параметры → 422. Поэтому мост:
забираем тело, вручную докручиваем ASGI-диалог, ответ собираем из
сообщений send (stateless + json_response=True — ответ всегда один
JSON, SSE-стримов тут нет).
"""
body = await request.body()
received = False
start: dict = {}
chunks: list[bytes] = []
async def receive():
nonlocal received
if received:
return {"type": "http.disconnect"}
received = True
return {"type": "http.request", "body": body, "more_body": False}
async def send(message) -> None:
if message["type"] == "http.response.start":
start.update(message)
else:
chunks.append(message.get("body", b""))
await mcp_asgi(request.scope, receive, send)
response = Response(
content=b"".join(chunks) if chunks else b"", status_code=start["status"]
)
response.raw_headers.extend(start.get("headers") or [])
return response