"""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.api.shares import list_shares
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}] — данных ещё нет")
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, диски.
"""
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