Newer
Older
gn-synapse / app / mcp / tools.py
"""Инструменты MCP: управление Synapse ИИ-агентом.

Каждый тул — синхронная функция (FastMCP гоняет её в threadpool — совместимо
с синхронной SQLAlchemy-сессией). Логика записи НЕ дублируется: тулы
переиспользуют admin-route функции (admin_routes.py) напрямую с
user=current_mcp_user(), db=<сессия> — Depends не срабатывают, та же
валидация и те же коды ошибок. HTTPException -> {"error": status, "detail"}.

Роли (docs/07): ключ действует ролью владельца. Админ-тулы под
_admin_run — для персонального ключа роли user: {"error": 403}.
Личный набор (me, my_events_list, my_push_*) — любая роль, всегда
отфильтрован по sub владельца (me_routes) или read-only.
"""

import json
import uuid
from typing import Any

from fastapi import HTTPException
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import BaseModel, ValidationError
from sqlalchemy import func, select, text

from app.api import admin_routes
from app.api.admin_schemas import (
    KeyIn,
    RuleIn,
    RulePatch,
    SettingsPut,
    SourceIn,
    TargetIn,
    TargetPatch,
    TypeIn,
)
from app.config import get_settings
from app.database import SessionLocal
from app.mcp.context import current_mcp_user
from app.models import (
    ChannelTarget,
    Delivery,
    Event,
    NotificationType,
    PushSubscription,
    RoutingRule,
    Source,
)
from app.worker.celery_app import celery_app

LIMIT_CAP = 500


def _run(fn) -> Any:
    """Сессия на вызов тулза; HTTPException/pydantic ValidationError -> dict ошибки."""
    try:
        with SessionLocal() as db:
            return fn(db)
    except HTTPException as err:
        return {"error": err.status_code, "detail": str(err.detail)}
    except ValidationError as err:
        return {"error": 422, "detail": err.errors()[0].get("msg", "невалидно")}


def _admin_denied() -> dict | None:
    """Роль владельца ключа — не admin/superadmin? -> dict ошибки 403."""
    user = current_mcp_user()
    if (user.system_role or "") in ("admin", "superadmin"):
        return None
    return {
        "error": 403,
        "detail": "Тул уровня админа — нужен MCP-ключ роли admin (выпустите заново на /mcp-keys)",
    }


def _admin_run(fn) -> Any:
    denial = _admin_denied()
    if denial is not None:
        # ToolError, а не return: FastMCP валидирует вывод по аннотации
        # (list-тулы бы отвергли dict ошибки), isError несёт текст как есть.
        raise ToolError(json.dumps(denial, ensure_ascii=False))
    return _run(fn)


def _patch(model_cls: type[BaseModel], **kwargs) -> BaseModel:
    """PATCH-схема только из переданных полей: None = «не менять»
    (exclude_unset в route-функции видит незаданные поля незаданными)."""
    return model_cls.model_validate({k: v for k, v in kwargs.items() if v is not None})


def register_tools(mcp) -> None:

    @mcp.tool()
    def system_status() -> dict:
        """Здоровье Synapse: БД, Celery-воркер, конфигурация (SSO)."""
        def fn(db):
            db.execute(text("SELECT 1"))
            return {"database": "ok"}
        out = _admin_run(fn)
        if "error" in out:
            return {"database": "unavailable", **out}
        try:
            pong = celery_app.send_task("synapse.ping").get(timeout=5)
            out.update(worker=pong, sso_configured=get_settings().gauth_configured())
        except Exception:  # noqa: BLE001 — недоступный воркер не ломает статус
            out.update(worker="unavailable", sso_configured=get_settings().gauth_configured())
        return out

    @mcp.tool()
    def stats_get() -> dict:
        """Сводка: события и доставки по статусам, число живых
        sources/types/targets/rules, число deliveries в ожидании."""
        def fn(db):
            ev = dict(
                db.execute(select(Event.status, func.count(Event.id)).group_by(Event.status)).all()
            )
            dv = dict(
                db.execute(
                    select(Delivery.status, func.count(Delivery.id)).group_by(Delivery.status)
                ).all()
            )
            return {
                "events": ev,
                "deliveries": dv,
                "pending_deliveries": dv.get("pending", 0),
                "sources": db.scalar(
                    select(func.count(Source.id)).where(Source.deleted_at.is_(None))
                ),
                "types": db.scalar(
                    select(func.count(NotificationType.id)).where(
                        NotificationType.deleted_at.is_(None)
                    )
                ),
                "targets": db.scalar(
                    select(func.count(ChannelTarget.id)).where(
                        ChannelTarget.deleted_at.is_(None)
                    )
                ),
                "rules": db.scalar(
                    select(func.count(RoutingRule.id)).where(RoutingRule.deleted_at.is_(None))
                ),
            }
        return _admin_run(fn)

    # --- источники и ключи (выдача ключей клиентам, docs/07) ---

    @mcp.tool()
    def sources_list(
        query: str | None = None, include_archived: bool = False, limit: int = 100
    ) -> list[dict]:
        """Источники событий. query — подстрока в name/label/description;
        include_archived=true — показать и заархивированные (deleted_at != null)."""
        def fn(db):
            rows = admin_routes.list_sources(
                include_archived=include_archived, user=current_mcp_user(), db=db
            )
            out = [r.model_dump(mode="json") for r in rows]
            if query:
                ql = query.lower()
                out = [
                    r for r in out
                    if ql in r["name"].lower()
                    or ql in (r.get("label") or "").lower()
                    or ql in (r.get("description") or "").lower()
                ]
            return out[: max(1, min(limit, LIMIT_CAP))]
        return _admin_run(fn)

    @mcp.tool()
    def source_create(name: str, label: str | None = None, description: str | None = None) -> dict:
        """Зарегистрировать источник событий (сервис). name — slug [a-z0-9._-],
        label/description — человекочитаемое «что это за сервис»."""
        def fn(db):
            src = admin_routes.create_source(
                SourceIn(name=name, label=label, description=description),
                user=current_mcp_user(), db=db,
            )
            return src.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def source_archive(source_id: int) -> dict:
        """Заархивировать источник (это не удаление): ключ перестаёт принимать
        события (401), source_restore возвращает в строй."""
        def fn(db):
            admin_routes.delete_source(source_id, user=current_mcp_user(), db=db)
            return {"ok": True, "archived": source_id}
        return _admin_run(fn)

    @mcp.tool()
    def source_restore(source_id: int) -> dict:
        """Вернуть заархивированный источник в строй. Конфликт имени → 409."""
        def fn(db):
            src = admin_routes.restore_source(source_id, user=current_mcp_user(), db=db)
            return src.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def keys_list(source_id: int) -> list[dict]:
        """API-ключи источника: хэш и хвост — plaintext никогда не возвращается,
        полный токен виден один раз в key_issue."""
        def fn(db):
            keys = admin_routes.list_keys(source_id, user=current_mcp_user(), db=db)
            return [k.model_dump(mode="json") for k in keys]
        return _admin_run(fn)

    @mcp.tool()
    def key_issue(source_id: int, name: str) -> dict:
        """Выдать API-ключ клиенту (сервису-источнику). Полный токен `syn_...`
        в поле token возвращается ОДИН РАЗ — передать клиенту (в его .env);
        в Synapse остаётся только хэш. Повторный вызов = новый токен."""
        def fn(db):
            created = admin_routes.create_key(
                source_id, KeyIn(name=name), user=current_mcp_user(), db=db
            )
            return created.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def key_revoke(source_id: int, key_id: int) -> dict:
        """Отозвать API-ключ источника (события с ним начнут получать 401)."""
        def fn(db):
            key = admin_routes.revoke_key(source_id, key_id, user=current_mcp_user(), db=db)
            return key.model_dump(mode="json")
        return _admin_run(fn)

    # --- типы уведомлений (тройка source/subject/action) ---

    @mcp.tool()
    def types_list(
        source_id: int | None = None,
        query: str | None = None,
        include_archived: bool = False,
        limit: int = 200,
    ) -> list[dict]:
        """Зарегистрированные типы уведомлений (тройка source/subject/action).
        Событие с незарегистрированной тройкой приём отклоняет (422)."""
        def fn(db):
            rows = admin_routes.list_types(
                include_archived=include_archived, user=current_mcp_user(), db=db
            )
            out = [r.model_dump(mode="json") for r in rows]
            if source_id is not None:
                out = [r for r in out if r["source_id"] == source_id]
            if query:
                ql = query.lower()
                out = [
                    r for r in out
                    if ql in r["subject"].lower()
                    or ql in r["action"].lower()
                    or ql in (r.get("description") or "").lower()
                ]
            return out[: max(1, min(limit, LIMIT_CAP))]
        return _admin_run(fn)

    @mcp.tool()
    def type_register(
        source_id: int,
        subject: str,
        action: str,
        description: str | None = None,
        payload_schema: dict | None = None,
    ) -> dict:
        """Зарегистрировать тип уведомления: тройка (source_id, subject, action).
        payload_schema — опциональная JSON Schema payload'а (валидация мягкая, в воркере)."""
        def fn(db):
            nt = admin_routes.create_type(
                TypeIn(
                    source_id=source_id, subject=subject, action=action,
                    payload_schema=payload_schema, description=description,
                ),
                user=current_mcp_user(), db=db,
            )
            return nt.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def type_archive(type_id: int) -> dict:
        """Заархивировать тип: приём его тройки начнёт отвечать
        422 «тип не зарегистрирован»; type_restore вернёт в строй."""
        def fn(db):
            admin_routes.delete_type(type_id, user=current_mcp_user(), db=db)
            return {"ok": True, "archived": type_id}
        return _admin_run(fn)

    @mcp.tool()
    def type_restore(type_id: int) -> dict:
        """Вернуть заархивированный тип в строй (источник должен быть жив)."""
        def fn(db):
            nt = admin_routes.restore_type(type_id, user=current_mcp_user(), db=db)
            return nt.model_dump(mode="json")
        return _admin_run(fn)

    # --- цели каналов ---

    @mcp.tool()
    def targets_list(
        channel: str | None = None,
        enabled: bool | None = None,
        include_archived: bool = False,
        limit: int = 200,
    ) -> list[dict]:
        """Цели каналов (TG-чат, email-адрес, s2s-точка). channel — фильтр."""
        def fn(db):
            rows = admin_routes.list_targets(
                include_archived=include_archived, user=current_mcp_user(), db=db
            )
            out = [r.model_dump(mode="json") for r in rows]
            if channel:
                out = [r for r in out if r["channel"] == channel]
            if enabled is not None:
                out = [r for r in out if r["enabled"] == enabled]
            return out[: max(1, min(limit, LIMIT_CAP))]
        return _admin_run(fn)

    @mcp.tool()
    def target_create(
        channel: str, name: str, config: dict, description: str | None = None
    ) -> dict:
        """Создать цель канала. channel ∈ telegram|email|s2s|internal_log;
        config — идентификатор цели (s2s: {"endpoint": …, "token_ref": "navi-rei"}).
        Секреты целей — в .env/gnexus-creds, в config только token_ref."""
        def fn(db):
            t = admin_routes.create_target(
                TargetIn(channel=channel, name=name, config=config, description=description),
                user=current_mcp_user(), db=db,
            )
            return t.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def target_patch(
        target_id: int, name: str | None = None, description: str | None = None,
        config: dict | None = None, enabled: bool | None = None,
    ) -> dict:
        """Частичное обновление цели (переданное — меняется, None — «не менять»).
        Канал не меняется (ссылки из правил)."""
        def fn(db):
            t = admin_routes.patch_target(
                target_id,
                _patch(TargetPatch, name=name, description=description,
                       config=config, enabled=enabled),
                user=current_mcp_user(), db=db,
            )
            return t.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def target_archive(target_id: int) -> dict:
        """Заархивировать цель: правила продолжат матчить, но доставки в неё
        запишутся skipped «цель в архиве»; restore вернёт в строй."""
        def fn(db):
            admin_routes.delete_target(target_id, user=current_mcp_user(), db=db)
            return {"ok": True, "archived": target_id}
        return _admin_run(fn)

    @mcp.tool()
    def target_restore(target_id: int) -> dict:
        """Вернуть заархивированную цель в строй. Конфликт имени → 409."""
        def fn(db):
            t = admin_routes.restore_target(target_id, user=current_mcp_user(), db=db)
            return t.model_dump(mode="json")
        return _admin_run(fn)

    # --- правила маршрутизации ---

    @mcp.tool()
    def rules_list(
        enabled: bool | None = None, include_archived: bool = False, limit: int = 200
    ) -> list[dict]:
        """Правила маршрутизации (условия -> действия). weight — порядок в режиме first."""
        def fn(db):
            rows = admin_routes.list_rules(include_archived=include_archived, user=current_mcp_user(), db=db)
            out = [r.model_dump(mode="json") for r in rows]
            if enabled is not None:
                out = [r for r in out if r["enabled"] == enabled]
            return out[: max(1, min(limit, LIMIT_CAP))]
        return _admin_run(fn)

    @mcp.tool()
    def rule_create(
        name: str,
        actions: list[dict],
        conditions: dict | None = None,
        template: str | None = None,
        throttle_seconds: int | None = None,
        weight: int = 0,
    ) -> dict:
        """Создать правило: действия [{"channel": "telegram|email|s2s|internal_log|user|push",
        "target_id": id|null, "template": "{{ payload.x }}"}]; условия
        {"source": "имя"|null, "subjects": [], "actions": [], "priority_min": "normal",
        "payload": {...}}. Каналы user/push целей не имеют (адресат payload.user_id)."""
        def fn(db):
            rule = admin_routes.create_rule(
                RuleIn.model_validate({
                    "name": name, "actions": actions,
                    "conditions": conditions or {}, "template": template,
                    "throttle_seconds": throttle_seconds, "weight": weight,
                }),
                user=current_mcp_user(), db=db,
            )
            return rule.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def rule_patch(
        rule_id: int, name: str | None = None, enabled: bool | None = None,
        conditions: dict | None = None, template: str | None = None,
        throttle_seconds: int | None = None, weight: int | None = None,
        actions: list[dict] | None = None,
    ) -> dict:
        """Частично обновить правило (вкл/выкл, условия, действия, шаблон, вес)."""
        def fn(db):
            rule = admin_routes.patch_rule(
                rule_id,
                _patch(RulePatch, name=name, enabled=enabled, conditions=conditions,
                       template=template, throttle_seconds=throttle_seconds,
                       weight=weight, actions=actions),
                user=current_mcp_user(), db=db,
            )
            return rule.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def rule_archive(rule_id: int) -> dict:
        """Заархивировать правило (действия сохраняются — restore вернёт целиком)."""
        def fn(db):
            admin_routes.delete_rule(rule_id, user=current_mcp_user(), db=db)
            return {"ok": True, "archived": rule_id}
        return _admin_run(fn)

    @mcp.tool()
    def rule_restore(rule_id: int) -> dict:
        """Вернуть заархивированное правило в строй."""
        def fn(db):
            rule = admin_routes.restore_rule(rule_id, user=current_mcp_user(), db=db)
            return rule.model_dump(mode="json")
        return _admin_run(fn)

    # --- поток: события и доставки ---

    @mcp.tool()
    def events_list(
        limit: int = 50, status: str | None = None, source_name: str | None = None
    ) -> list[dict]:
        """Последние события (конверт + payload + доставки). status ∈
        queued|processing|done|failed|expired; source_name — фильтр по имени источника."""
        def fn(db):
            source_id = None
            if source_name:
                src = db.execute(
                    select(Source.id).where(Source.name == source_name)
                ).scalar_one_or_none()
                source_id = src if src is not None else -1  # нет источника — пусто
            rows = admin_routes.list_events(
                user=current_mcp_user(), db=db, limit=limit, status_filter=status, source_id=source_id
            )
            return [r.model_dump(mode="json") for r in rows]
        return _admin_run(fn)

    @mcp.tool()
    def event_get(event_id: str) -> dict:
        """Событие по id: конверт, статусы и доставки в каналы."""
        def fn(db):
            ev = admin_routes.get_event(uuid.UUID(event_id), user=current_mcp_user(), db=db)
            return ev.model_dump(mode="json")
        return _admin_run(fn)

    @mcp.tool()
    def deliveries_list(
        limit: int = 100, status: str | None = None, channel: str | None = None
    ) -> list[dict]:
        """Журнал доставок: канал, цель, статус, попытки, ошибки ретраев.
        status ∈ pending|delivered|skipped|failed."""
        def fn(db):
            rows = admin_routes.list_deliveries(
                user=current_mcp_user(), db=db, limit=limit, status_filter=status, channel=channel
            )
            return [r.model_dump(mode="json") for r in rows]
        return _admin_run(fn)

    @mcp.tool()
    def push_subscriptions_list(user_id: str | None = None, limit: int = 100) -> list[dict]:
        """Web-push подписки пользователей (адресат — payload.user_id).
        user_id — фильтр по sub gnexus-auth."""
        def fn(db):
            q = select(PushSubscription).order_by(PushSubscription.id.desc())
            if user_id:
                q = q.where(PushSubscription.user_id == user_id)
            rows = db.execute(q.limit(max(1, min(limit, LIMIT_CAP)))).scalars().all()
            return [
                {
                    "id": s.id, "user_id": s.user_id, "endpoint": s.endpoint,
                    "ua": s.ua, "created_at": s.created_at.isoformat(),
                }
                for s in rows
            ]
        return _admin_run(fn)

    @mcp.tool()
    def push_subscription_delete(subscription_id: int) -> dict:
        """Удалить web-push подписку: единственное ЖЁСТКОЕ удаление в Synapse —
        устройство пользователя больше не подписано."""
        def fn(db):
            sub = db.get(PushSubscription, subscription_id)
            if sub is None:
                raise HTTPException(status_code=404, detail="Подписка не найдена")
            db.delete(sub)
            db.commit()
            return {"ok": True, "deleted": subscription_id}
        return _admin_run(fn)

    # --- настройки ---

    @mcp.tool()
    def settings_get() -> list[dict]:
        """Реестр настроек Synapse: дефолт .env + оверрайды админки.
        Секреты write-only — value всегда null, факт наличия в set."""
        def fn(db):
            rows = admin_routes.list_settings(user=current_mcp_user(), db=db)
            return [r.model_dump(mode="json") for r in rows]
        return _admin_run(fn)

    @mcp.tool()
    def settings_put(values: dict) -> dict:
        """Установить оверрайды настроек (поверх .env). Правила: строка — set,
        null — вернуть дефолт .env, "" у секрета — «не менять». Нарушение — 422."""
        def fn(db):
            return admin_routes.put_settings(
                SettingsPut.model_validate({"values": values}), user=current_mcp_user(), db=db
            )
        return _admin_run(fn)

    # --- личный набор (любая роль, фильтр по sub владельца ключа; docs/07) ---

    @mcp.tool()
    def me() -> dict:
        """Кто за ключом: sub/email/роль снейпшота выпуска MCP-токена."""
        user = current_mcp_user()
        return {
            "user_id": str(user.user_id),
            "email": user.email,
            "system_role": user.system_role,
        }

    @mcp.tool()
    def my_events_list(limit: int = 50) -> list[dict]:
        """События, доставленные владельцу ключа лично (канал user,
        адресат payload.user_id == sub). Тот же путь, что «Мои события» SPA."""
        from app.api.me_routes import my_events

        def fn(db):
            rows = my_events(user=current_mcp_user(), db=db, limit=max(1, min(limit, 200)))
            return [r.model_dump(mode="json") for r in rows]
        return _run(fn)

    @mcp.tool()
    def my_push_subscriptions_list() -> list[dict]:
        """Web-push подписки владельца ключа (его браузерные устройства)."""
        from app.api.me_routes import my_subscriptions

        def fn(db):
            rows = my_subscriptions(user=current_mcp_user(), db=db)
            return [r.model_dump(mode="json") for r in rows]
        return _run(fn)

    @mcp.tool()
    def my_push_subscription_delete(subscription_id: int) -> dict:
        """Отписать СВОЮ web-push подписку (чужая — 404); жёсткое удаление устройства."""
        from app.api.me_routes import remove_subscription

        def fn(db):
            remove_subscription(subscription_id, user=current_mcp_user(), db=db)
            return {"ok": True, "deleted": subscription_id}
        return _run(fn)

    # --- приём контрольного события ---

    @mcp.tool()
    def send_test_event(
        source_name: str, subject: str, action: str, payload: dict | None = None,
        priority: str = "normal", ttl_seconds: int | None = None,
    ) -> dict:
        """Контрольное событие «как бы от источника» (source_name): полный
        маршрут в воркере -> доставки по правилам. Для проверки нового правила.
        Тройка должна быть зарегистрирована (type_register)."""
        from app.api.events_routes import accept_event
        from app.api.schemas import EventEnvelope

        def fn(db):
            src = db.execute(
                select(Source).where(Source.name == source_name)
            ).scalar_one_or_none()
            if src is None:
                raise HTTPException(status_code=404, detail=f"Источник '{source_name}' не найден")
            if src.deleted_at is not None:
                raise HTTPException(
                    status_code=422, detail=f"Источник '{source_name}' в архиве — сначала restore"
                )
            envelope = EventEnvelope(
                source=source_name, subject=subject, action=action, priority=priority,
                payload=payload or {}, ttl_seconds=ttl_seconds,
            )
            event = accept_event(db, src, envelope)
            db.commit()
            celery_app.send_task("synapse.ingest", args=[str(event.id)])
            return {"id": str(event.id), "status": event.status, "source": source_name}
        return _admin_run(fn)