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

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

import uuid
from typing import Any

from fastapi import HTTPException
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.auth.deps import AuthenticatedUser
from app.config import get_settings
from app.database import SessionLocal
from app.models import (
    ChannelTarget,
    Delivery,
    Event,
    NotificationType,
    PushSubscription,
    RoutingRule,
    Source,
)
from app.worker.celery_app import celery_app

# Синтетический superadmin: require_admin пропускает, автор — MCP-токен.
MCP_USER = AuthenticatedUser(
    user_id="mcp", email="mcp@synapse.local", email_verified=True, system_role="superadmin"
)

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 _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 = _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 _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=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 _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=MCP_USER, db=db,
            )
            return src.model_dump(mode="json")
        return _run(fn)

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

    @mcp.tool()
    def source_restore(source_id: int) -> dict:
        """Вернуть заархивированный источник в строй. Конфликт имени → 409."""
        def fn(db):
            src = admin_routes.restore_source(source_id, user=MCP_USER, db=db)
            return src.model_dump(mode="json")
        return _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=MCP_USER, db=db)
            return [k.model_dump(mode="json") for k in keys]
        return _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=MCP_USER, db=db
            )
            return created.model_dump(mode="json")
        return _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=MCP_USER, db=db)
            return key.model_dump(mode="json")
        return _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=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 _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=MCP_USER, db=db,
            )
            return nt.model_dump(mode="json")
        return _run(fn)

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

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

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

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

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

    @mcp.tool()
    def rule_restore(rule_id: int) -> dict:
        """Вернуть заархивированное правило в строй."""
        def fn(db):
            rule = admin_routes.restore_rule(rule_id, user=MCP_USER, db=db)
            return rule.model_dump(mode="json")
        return _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=MCP_USER, db=db, limit=limit, status_filter=status, source_id=source_id
            )
            return [r.model_dump(mode="json") for r in rows]
        return _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=MCP_USER, db=db)
            return ev.model_dump(mode="json")
        return _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=MCP_USER, db=db, limit=limit, status_filter=status, channel=channel
            )
            return [r.model_dump(mode="json") for r in rows]
        return _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 _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 _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=MCP_USER, db=db)
            return [r.model_dump(mode="json") for r in rows]
        return _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=MCP_USER, db=db
            )
        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 _run(fn)