diff --git a/panel/backend/app/mcp.py b/panel/backend/app/mcp.py index 3fd4eec..dd4f366 100644 --- a/panel/backend/app/mcp.py +++ b/panel/backend/app/mcp.py @@ -7,32 +7,48 @@ Инструкции агенту (AGENT_INSTRUCTIONS) отдаются и как instructions инициализации, и как prompt agent_guide — паттерн reference-реализации -gnexus-creds (data_api). Все тулы read-only: агент — наблюдатель. +gnexus-creds (data_api). Тулы — чтение плюс управление серверами и +сервисами (запись по явной просьбе человека). """ import json +import time from datetime import datetime, timedelta, timezone -from fastapi import Request +from fastapi import HTTPException, Request +from pydantic import ValidationError 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.servers import ( + create_server as _create_server, + delete_server as _delete_server, + get_server, + list_servers, +) +from app.api.services import ( + ServiceCreate as _ServiceCreate, + create_service as _create_service, + delete_service as _delete_service, + list_services as _list_services, + service_incidents as _service_incidents, +) from app.api.shares import list_shares as _list_shares from app.db import get_db +from app.models import ServerCreate as _ServerCreate AGENT_INSTRUCTIONS = """\ # GHard Monitor — инструкция для ИИ-агента ## Назначение Этот сервер — MCP-интерфейс панели мониторинга GHard Monitor -(Gnexus Hardware Monitor). Ты — наблюдатель-диагност: отвечаешь -пользователю о состоянии его серверов и сервисов. Все инструменты — -**только чтение**: менять настройки, удалять и перезапускать ты не можешь -(и это хорошо — панельные изменения делает человек через веб-интерфейс). +(Gnexus Hardware Monitor). Основная твоя роль — наблюдатель-диагност: +отвечаешь пользователю о состоянии его серверов и сервисов. Читающие +тулы дают полную картину поверх наблюдения; записывающие тулы +(создание/удаление серверов и сервисов) — только по явной просьбе +человека: панель общая, её изменения видны всем. +Перезапускать агентов и сервисы ты не можешь — это за пределами панели. ## Данные - **Серверы** — машины с установленным агентом hard-monitor: CPU/RAM/swap, @@ -57,10 +73,31 @@ | «растёт ли диск на db-01 за сутки?» / тренд | `server_history` | | «сервис падал?», «с какими сервисами проблемы?» | `services_overview`, потом `service_incidents` (id — из overview) | | «что происходило ночью / за последние часы?» | `events_recent` | +| «добавь сервер/сервис», «удали web-old» | тулы из «Управление (запись)» | Порядок для составных вопросов: обзор → выбранная детализация. `panel_overview` дешёвый — начинай с него, если не уверен. +## Управление (запись) +Четыре тула меняют каталог. Запись лимитирована: ≤20 записывающих вызовов +за 10 минут, сверх того возвращается `{"error": 429}`. +- `server_create(name, hostname="", interval=30)` — регистрирует сервер и + возвращает ключ агента `ghm_…`. Ключ показывается **ровно один раз — + сохрани его сразу** и передай пользователю (в .env агента). До установки + агента сервер остаётся `pending`. +- `server_delete(server_id)` — удаляет сервер **необратимо**: его метрики + и журнал событий стираются каскадом. +- `service_create(name, url)` — добавляет health-чек; первая проба + выполняется сразу, статус читай через `services_overview`. +- `service_delete(service_id)` — удаляет сервис; история его проб стирается. + +Правила записи: +- удалять — **только по явной просьбе пользователя** («удали», «убери»), + не по собственным соображениям; перед удалением назови, что сотрётся; +- ошибки записи приходят как данные: `{"error": 404|422|429, "detail": "…"}` + — прочти detail и действуй по нему; +- результат создания (id, ключ, URL) возвращай пользователю — это его данные. + ## Словарь статусов - Сервер: `online` (пакеты идут) / `offline` (панель не получает метрики — агент умер, сеть или машина выключена) / данных нет. @@ -91,8 +128,9 @@ единицы самостоятельно. - Если сервер offline — не паникуй и не повторяй вопросы тулами; скажи последнее известное состояние (в `server_details`) и время последнего пакета. -- Пользователь просит «что-нибудь сделать» (перезапустить, удалить) — - объясни, что доступ только на чтение, изменения — через веб-интерфейс. +- Пользователь просит «что-нибудь сделать» (перезапустить, поправить) — + перезапуски за пределами панели, а создание и удаление — тулы из + «Управление (запись)», только по явной просьбе. ## Подключение (для администратора панели) ``` @@ -126,6 +164,33 @@ return json.dumps(data, ensure_ascii=False, indent=1) +def _err(code: int, detail: str) -> str: + """Ошибка записывающего тула как данные (канон handbook mcp.md).""" + return json.dumps({"error": code, "detail": detail}, ensure_ascii=False) + + +# Rate-limit записи (канон mcp.md: чувствительные операции). Окно в памяти +# процесса, глобальное — тула не видит личность звонящего (FastMCP этой +# версии не пробрасывает HTTP-контекст), для защиты от спама хватает. +_WRITE_WINDOW = 600.0 +_WRITE_MAX = 20 +_write_log: list[float] = [] + + +def _write_gate() -> str | None: + """None — проход; строка — JSON-ошибка 429 (лимит записи израсходован).""" + now = time.monotonic() + while _write_log and now - _write_log[0] > _WRITE_WINDOW: + del _write_log[0] + if len(_write_log) >= _WRITE_MAX: + return _err( + 429, + f"too many write calls (>={_WRITE_MAX} за {int(_WRITE_WINDOW / 60)} мин) — повторите позже", + ) + _write_log.append(now) + return None + + def _worst_disk(disks: list[dict] | None) -> dict | None: if not disks: return None @@ -293,6 +358,78 @@ return _dumps(events) +# --- Управление (запись): серверы и сервисы ------------------------------------ + + +@mcp.tool() +async def server_create(name: str, hostname: str = "", interval: int = 30) -> str: + """Зарегистрировать сервер и получить ключ агента. Ключ (ghm_…) показан + ОДИН раз — сразу сохрани его для .env агента. До установки агента сервер + в статусе pending.""" + if gate := _write_gate(): + return gate + if not name.strip(): + return _err(422, "name обязателен") + if not 5 <= interval <= 3600: + return _err(422, "interval: 5..3600 секунд") + try: + created = await _create_server( + _ServerCreate(name=name.strip(), hostname=hostname.strip(), interval=interval) + ) + except ValidationError as exc: + return _err(422, str(exc)) + return ( + f"Сервер #{created['id']} «{created['name']}» создан.\n" + f"Ключ агента (показан один раз — сохрани немедленно):\n" + f"{created['key']}\n" + f"До установки агента (PANEL_URL + SERVER_KEY + INTERVAL в .env) сервер в статусе pending." + ) + + +@mcp.tool() +async def server_delete(server_id: int) -> str: + """Удалить сервер НЕОБРАТИМО: его метрики и журнал событий стираются + каскадом. Вызывай только по явной просьбе пользователя.""" + if gate := _write_gate(): + return gate + try: + await _delete_server(server_id) + except HTTPException as exc: + return _err(exc.status_code, str(exc.detail)) + return f"Сервер #{server_id} удалён; его метрики и журнал стёрты безвозвратно." + + +@mcp.tool() +async def service_create(name: str, url: str) -> str: + """Добавить health-чек сервиса (name до 100 симв., url до 500). + Первая проба выполняется сразу; статус — через services_overview.""" + if gate := _write_gate(): + return gate + if not url.strip(): + return _err(422, "url обязателен") + try: + created = await _create_service(_ServiceCreate(name=name.strip(), url=url.strip())) + except ValidationError: + return _err(422, "name: 1..100 символов, url: 1..500 символов") + return ( + f"Сервис #{created['id']} «{created['name']}» добавлен: {created['url']}\n" + f"Первая проба выполнена (или доберёт фоновый цикл) — статус читай в services_overview." + ) + + +@mcp.tool() +async def service_delete(service_id: int) -> str: + """Удалить сервис НЕОБРАТИМО: история его проб стирается. Вызывай только + по явной просьбе пользователя.""" + if gate := _write_gate(): + return gate + try: + await _delete_service(service_id) + except HTTPException as exc: + return _err(exc.status_code, str(exc.detail)) + return f"Сервис #{service_id} удалён; история его проб стёрта безвозвратно." + + mcp_app = mcp.streamable_http_app() # ASGI-эндпоинт MCP: регистрируется прямо в FastAPI на /mcp (см. app.main),