diff --git a/README.md b/README.md index 59df1b8..2c723cd 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,35 @@ - `PATCH /api/v1/servers/{id}` — имя / заметка - `DELETE /api/v1/servers/{id}` — удалить сервер (с каскадом метрик) +## MCP для ИИ-агентов + +Панель сама является MCP-сервером (streamable HTTP, по умолчанию `/mcp`) +— любой ИИ-агент подключается по URL панели и читает состояние серверов. + +Инструменты: + +| Tool | Что отдаёт | +| --- | --- | +| `panel_overview` | все серверы одной сводкой: статус, CPU/RAM/диск, сеть, последний пакет | +| `server_details` | полное состояние сервера: метрики, диски, процессы, docker, заметка | +| `server_history` | история метрик за N часов, прореженная до N точек | + +Подключение (Claude Code): + +```bash +claude mcp add --transport http ghard https://panel.example.com/mcp +``` + +Для любого другого MCP-клиента — тот же URL, транспорт streamable HTTP. +После подключения агент может отвечать на вопросы «как мои серверы?», +«что тормозит на web-01?», «растёт ли диск на db-01 за сутки?» без +доступа к веб-интерфейсу. + +**Заметка про токен**: `/mcp` сейчас открыт так же, как REST API при +пустом `GHARD_ADMIN_TOKEN`. Если панель торчит в интернет и ты задаёшь +токен — закрой `/mcp` на reverse proxy или оставь доступ только из +доверенной сети, иначе любой сможет читать метрики. + ## Статус - [x] Этап 1 — скелет: FastAPI, SQLite-схема, ingest, servers API, docker diff --git a/panel/backend/app/main.py b/panel/backend/app/main.py index 2b01df6..f0907f7 100644 --- a/panel/backend/app/main.py +++ b/panel/backend/app/main.py @@ -3,11 +3,13 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.routing import Route from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app.api import ingest, servers from app.db import close_db, init_db +from app.mcp import mcp, mcp_asgi # Собранный Vue-фронт (panel/frontend/dist) попадает сюда в docker-образе; # в dev фронт крутится отдельно (vite, proxy /api → localhost:8000) @@ -17,7 +19,10 @@ @asynccontextmanager async def lifespan(_: FastAPI): await init_db() - yield + # lifespan смонтированных sub-app не вызывается — MCP session manager + # стартуем здесь, иначе /mcp отвечает 500 ("Task group is not initialized") + async with mcp.session_manager.run(): + yield await close_db() @@ -34,6 +39,11 @@ app.include_router(ingest.router) app.include_router(servers.router) +# MCP для ИИ-агентов (streamable HTTP, stateless) — POST/GET/DELETE /mcp +app.router.routes.append( + Route("/mcp", mcp_asgi, methods=["GET", "POST", "DELETE"]) +) + @app.get("/api/v1/health") async def health() -> dict: diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py new file mode 100644 index 0000000..71318ad --- /dev/null +++ b/panel/backend/app/mcp.py @@ -0,0 +1,123 @@ +"""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 \ No newline at end of file diff --git a/panel/backend/pyproject.toml b/panel/backend/pyproject.toml index 8e385ca..aaad607 100644 --- a/panel/backend/pyproject.toml +++ b/panel/backend/pyproject.toml @@ -9,6 +9,7 @@ "aiosqlite>=0.20", "pydantic>=2.7", "pydantic-settings>=2.3", + "mcp>=1.10,<2", ] [build-system]