Newer
Older
hard-panel / panel / backend / app / mcp.py
"""MCP-сервер панели: любые ИИ-агенты читают состояние серверов по /mcp.

Транспорт — streamable HTTP (stateless + JSON-ответы), приложение
монтируется в FastAPI в app.main на /mcp. Клиент Claude Code:

    claude mcp add --transport http ghard https://panel.example.com/mcp
"""

import json
from datetime import datetime, timedelta, timezone

from mcp.server.fastmcp import FastMCP

from app.api.servers import get_server, list_servers
from app.db import get_db

mcp = FastMCP(
    "GHard Monitor",
    instructions=(
        "Мониторинг серверов GHard Monitor. Начни с panel_overview, чтобы "
        "увидеть все серверы и их статус; server_details даёт текущие метрики "
        "и процессы конкретной машины, server_history — историю для графиков/трендов."
    ),
    stateless_http=True,  # без сессий — работает за reverse proxy
    json_response=True,
    streamable_http_path="/",
)


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/диски, сеть, последний пакет.

    Самый быстрый способ ответить на «как мои серверы?» / «что с ним сейчас?».
    """
    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}] — данных ещё нет")
    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, диски.
    """
    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})


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