Newer
Older
gnexus-tasks / backend / app / mcp_server.py
"""MCP-сервер (M5, ТЗ 3.10): инструменты для ИИ-агентов.

Заточен под небольшие модели: подробные описания тулов и параметров,
адресация проекта по имени, ошибки с подсказкой следующего шага. Агент
действует от имени пользователя; мультитенантности нет. Аутентификация
временно — bearer-токен из конфига (MCP_TOKEN), до интеграции SSO-токенов.

Инструменты создают свои сессии к БД (вне HTTP-зависимостей FastAPI).
"""

import threading
from datetime import date
from typing import Annotated, Any, cast

from mcp.server.fastmcp import FastMCP
from pydantic import Field
from sqlalchemy import and_, func, select

from app.db import get_session_factory
from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow
from app.realtime import publish
from app.schemas import DEADLINE_PERIODS, RECUR_KINDS
from app.services.closing import handle_task_closed
from app.services.detailing import detail_task
from app.services.xp import coins_for_create, grant_create_xp

VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"}

# Серверные инструкции — агент видит их до вызова любого тула (ТЗ 3.10).
# Дисциплина проектного контекста: задачи не должны отрываться от проекта,
# а контекст разговора восстанавливается через get_project.
INSTRUCTIONS = """Личный менеджер задач GNexus Tasks (gntodo).

ПОРЯДОК РАБОТЫ
1. Начинайте с проектов: list_projects() покажет проекты и их заметки;
   get_project(project_id=... или name="...") вернёт заметку проекта и его
   открытые задачи — это контекст, в рамках которого живут задачи.
2. Создавая или обновляя задачу, привязывайте её к проекту — передавайте
   project_id или project_name. В ответе есть поле project: проверяйте, что
   задача попала в нужный проект. Задача без проекта легко теряется.
3. Возвращаясь к разговору после перерыва, перечитайте get_project(...) —
   заметка и открытые задачи восстановят контекст.
4. Берите id ТОЛЬКО из ответов инструментов (задачи, проекты, теги) — не
   придумывайте их. Ошибки подсказывают следующий шаг (например,
   «call list_projects()») — следуйте подсказке.

ЗАКРЫТИЕ ЗАДАЧИ
update_task(task_id, status="done") или complete_task(task_id, actual_minutes=N).
Награды (XP, монеты, растение в саду) начисляются автоматически; повторное
закрытие дублей не даёт. У регулярной задачи следующий экземпляр создаётся сам
(поле spawned_next_id в ответе) — вручную создавать его не нужно.

ПОИСК
list_tasks(query="подстрока", project_id=..., status=...) ищет по заголовку и
описанию; get_task(task_id) даёт полные данные. Найдя задачу, работайте с ней
по её id.
"""

# streamable_http_path="/" — чтобы под /mcp основного приложения маршрут
# FastMCP не дублировался (иначе он оказывается на /mcp/mcp)
mcp = FastMCP("gntodo", streamable_http_path="/", instructions=INSTRUCTIONS)


def _compact(task: Task) -> dict[str, Any]:
    return {
        "id": task.id,
        "title": task.title,
        "status": task.status,
        "detail_state": task.detail_state,
        "project_id": task.project_id,
        "project": task.project.name if task.project else None,
        "tags": [t.name for t in task.tags],
        "priority": task.priority,
        "parent_task_id": task.parent_task_id,
        "task_type": task.task_type,
        "deadline_date": task.deadline_date.isoformat() if task.deadline_date else None,
        "deadline_period": task.deadline_period,
        "recur_kind": task.recur_kind,
        "recur_interval_days": task.recur_interval_days,
        "recur_weekdays": task.recur_weekdays,
        "recur_day_of_month": task.recur_day_of_month,
    }


def _full(task: Task, db: Any) -> dict[str, Any]:
    data = _compact(task)
    data.update(
        {
            "description": task.description,
            "estimated_minutes": task.estimated_minutes,
            "actual_minutes": task.actual_minutes,
            "budget_money": task.budget_money,
            "cost_estimate_money": task.cost_estimate_money,
            "created_at": task.created_at.isoformat(),
            "done_at": task.done_at.isoformat() if task.done_at else None,
            "attachments": [
                {"id": a.id, "original_name": a.original_name, "mime": a.mime, "size": a.size}
                for a in db.scalars(
                    select(Attachment).where(Attachment.document_id == task.document_id)
                ).all()
            ],
        }
    )
    return data


def _get_task(db: Any, task_id: int) -> Task:
    task = cast(Task | None, db.get(Task, task_id))
    if task is None:
        raise ValueError(
            f"Task {task_id} not found — find the right id with list_tasks(query=...)."
        )
    return task


def _resolve_project(db: Any, project_id: int | None, project_name: str | None) -> Project | None:
    """Проект по id или имени (регистронезависимо); ничего не задано — None.

    project_id приоритетнее. Ошибка всегда подсказывает, как найти верный
    проект, — маленькая модель должна понимать следующий шаг (ТЗ 3.10).
    """
    if project_id is not None:
        project = cast(Project | None, db.get(Project, project_id))
        if project is None:
            raise ValueError(
                f"Project {project_id} not found — call list_projects() to get valid ids."
            )
        return project
    if project_name is not None:
        # Сравнение в Python: lower() в SQLite понимает только ASCII,
        # кириллические имена проектов не сматчились бы (ТЗ 3.10)
        wanted = project_name.strip().lower()
        project = cast(
            Project | None,
            next(
                (p for p in db.scalars(select(Project)).all() if p.name.lower() == wanted),
                None,
            ),
        )
        if project is None:
            raise ValueError(
                f"Project '{project_name}' not found — call list_projects() "
                "to see existing projects."
            )
        return project
    return None


def _project_compact(project: Project, db: Any) -> dict[str, Any]:
    """Проект для списка: контекст в сжатом виде, заметка обрезана."""
    open_tasks = (
        db.scalar(
            select(func.count(Task.id)).where(
                Task.project_id == project.id, Task.status.in_(("to_do", "in_progress"))
            )
        )
        or 0
    )
    note = project.note
    return {
        "id": project.id,
        "name": project.name,
        "relevance_status": project.relevance_status,
        "is_archived": project.is_archived,
        "open_tasks": open_tasks,
        "note": note[:300] + "…" if len(note) > 300 else note,
    }


# --- инструменты (логика отдельно от регистрации — тестируется напрямую) ---


def create_task(
    title: Annotated[str, Field(description="Короткий текст задачи (обязателен)")],
    description: Annotated[str, Field(description="Подробности и шаги, markdown")] = "",
    parent_task_id: Annotated[
        int | None, Field(description="id родительской задачи, если это подзадача")
    ] = None,
    project_id: Annotated[
        int | None,
        Field(description="id проекта (из list_projects); приоритетнее project_name"),
    ] = None,
    project_name: Annotated[
        str | None, Field(description="Имя проекта (регистронезависимо) — если id неизвестен")
    ] = None,
    tag_ids: Annotated[
        list[int] | None, Field(description="id тегов (список из list_tags)")
    ] = None,
    priority: Annotated[
        int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный")
    ] = None,
) -> dict[str, Any]:
    """Создать задачу; достаточно title — остальное дописывается через update_task.

    По возможности привяжите задачу к проекту (project_id или project_name) —
    задача без проекта теряется. Автодетализация подставит метаданные в фоне.
    Ответ содержит поле project — проверьте привязку.
    """
    session = get_session_factory()()
    try:
        if parent_task_id is not None:
            _get_task(session, parent_task_id)
        task = Task(
            title=title,
            description=description,
            parent_task_id=parent_task_id,
            priority=priority,
        )
        project = _resolve_project(session, project_id, project_name)
        if project is not None:
            task.project_id = project.id
        if tag_ids:
            tags = list(session.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all())
            if len(tags) != len(set(tag_ids)):
                raise ValueError("Unknown tag id in tag_ids — get ids from list_tags()")
            task.tags = tags
        session.add(task)
        session.flush()
        # Микронаграда за создание (ТЗ 3.13) — как в HTTP-API
        grant_create_xp(session, "create_task")
        session.add(CoinEvent(source="create_task", amount=coins_for_create("create_task")))
        session.commit()
        # Агент меняет данные вне UI — уведомляем все открытые SSE-соединения
        publish(None, "task.changed", {"id": task.id, "source": "mcp"})
        publish(None, "xp.changed", {"celebrate": False})
        # Автодетализация — в фоновом потоке (LLM работает десятки секунд)
        threading.Thread(target=detail_task, args=(task.id,), daemon=True).start()
        result: dict[str, Any] = {
            "id": task.id,
            "title": task.title,
            "detail_state": task.detail_state,
            "project": task.project.name if task.project else None,
        }
        if task.project is None:
            result["hint"] = (
                "Проект не задан — передайте project_id/project_name "
                "(см. list_projects()), чтобы задача не потерялась."
            )
        return result
    finally:
        session.close()


def update_task(
    task_id: Annotated[int, Field(description="id задачи (из list_tasks/get_task)")],
    title: Annotated[str | None, Field(description="Новый заголовок")] = None,
    description: Annotated[str | None, Field(description="Новое описание, markdown")] = None,
    status: Annotated[
        str | None,
        Field(
            description=(
                "to_do | in_progress | done | cancelled | deferred. "
                "done закрывает задачу — награды начисляются автоматически"
            )
        ),
    ] = None,
    project_id: Annotated[
        int | None,
        Field(description="id проекта (из list_projects); приоритетнее project_name"),
    ] = None,
    project_name: Annotated[
        str | None, Field(description="Имя проекта (регистронезависимо) — если id неизвестен")
    ] = None,
    parent_task_id: Annotated[
        int | None, Field(description="id родительской задачи (перенос под другую задачу)")
    ] = None,
    priority: Annotated[
        int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный")
    ] = None,
    estimated_minutes: Annotated[
        int | None, Field(description="Оценка времени в минутах (влияет на XP за закрытие)")
    ] = None,
    actual_minutes: Annotated[
        int | None, Field(description="Фактически потраченное время в минутах")
    ] = None,
    budget_money: Annotated[int | None, Field(description="Бюджет в единой валюте")] = None,
    cost_estimate_money: Annotated[
        int | None, Field(description="Оценка стоимости в единой валюте")
    ] = None,
    deadline_date: Annotated[
        str | None, Field(description="Строгий дедлайн, ISO-дата (например 2026-10-01)")
    ] = None,
    deadline_period: Annotated[
        str | None, Field(description="Нестрогий срок: day | week | month | year")
    ] = None,
    task_type: Annotated[
        str | None, Field(description="one_time (разовая) | recurring (регулярная)")
    ] = None,
    recur_kind: Annotated[
        str | None,
        Field(description="Правило повторения: interval | weekdays | monthly"),
    ] = None,
    recur_interval_days: Annotated[
        int | None, Field(description="Для recur_kind=interval: раз в N дней")
    ] = None,
    recur_weekdays: Annotated[
        str | None, Field(description="Для recur_kind=weekdays: дни через запятую, пн=1..вс=7")
    ] = None,
    recur_day_of_month: Annotated[
        int | None, Field(description="Для recur_kind=monthly: число месяца 1-31")
    ] = None,
    tag_ids: Annotated[
        list[int] | None, Field(description="Полный новый список id тегов (из list_tags)")
    ] = None,
) -> dict[str, Any]:
    """Частично обновить задачу — передавайте только нужные поля.

    Регулярная задача: task_type="recurring" и правило recur_kind (interval:
    recur_interval_days; weekdays: recur_weekdays "1,3,5" пн=1..вс=7; monthly:
    recur_day_of_month). Закрытие (status="done") начисляет награды и создаёт
    следующий экземпляр регулярной задачи автоматически. Ответ содержит поле
    project — проверяйте, что задача осталась в нужном проекте.
    """
    session = get_session_factory()()
    try:
        # Блокировка строки: параллельные закрытия агентами сериализуются,
        # двойных наград/спавнов нет (тот же механизм, что в PATCH HTTP-API)
        task = cast(
            Task | None,
            # of=Task — как в PATCH HTTP-API: FOR UPDATE не применим к nullable-
            # стороне outer join (project joined-ом), блокируем только задачу
            session.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)),
        )
        if task is None:
            raise ValueError(
                f"Task {task_id} not found — find the right id with list_tasks(query=...)."
            )
        if title is not None:
            if not title.strip():
                raise ValueError("title cannot be empty")
            task.title = title
        if description is not None:
            task.description = description
        if status is not None:
            if status not in VALID_STATUSES:
                raise ValueError(f"Unknown status: {status}")
            task.status = status
        if deadline_date is not None:
            if not deadline_date.strip():
                task.deadline_date = None
            else:
                try:
                    task.deadline_date = date.fromisoformat(deadline_date)
                except ValueError:
                    raise ValueError(f"Invalid deadline_date: {deadline_date}") from None
        if deadline_period is not None:
            if deadline_period not in (None, "") and deadline_period not in DEADLINE_PERIODS:
                raise ValueError(f"Unknown deadline_period: {deadline_period}")
            task.deadline_period = deadline_period or None
        if task_type is not None:
            if task_type not in ("one_time", "recurring"):
                raise ValueError(f"Unknown task_type: {task_type}")
            task.task_type = task_type
        if recur_kind is not None:
            if recur_kind not in (None, "") and recur_kind not in RECUR_KINDS:
                raise ValueError(f"Unknown recur_kind: {recur_kind}")
            task.recur_kind = recur_kind or None
        if recur_interval_days is not None:
            task.recur_interval_days = recur_interval_days
        if recur_weekdays is not None:
            task.recur_weekdays = recur_weekdays or None
        if recur_day_of_month is not None:
            task.recur_day_of_month = recur_day_of_month
        if project_id is not None or project_name is not None:
            project = _resolve_project(session, project_id, project_name)
            assert project is not None  # ветка входит только при заданном проекте
            task.project_id = project.id
        if parent_task_id is not None:
            _get_task(session, parent_task_id)
            task.parent_task_id = parent_task_id
        if priority is not None:
            task.priority = priority
        if estimated_minutes is not None:
            task.estimated_minutes = estimated_minutes
        if actual_minutes is not None:
            task.actual_minutes = actual_minutes
        if budget_money is not None:
            task.budget_money = budget_money
        if cost_estimate_money is not None:
            task.cost_estimate_money = cost_estimate_money
        if tag_ids is not None:
            tags = list(session.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all())
            if len(tags) != len(set(tag_ids)):
                raise ValueError("Unknown tag id in tag_ids")
            task.tags = tags
        # Закрытие — переход в done (а не «уже закрытая»): тот же общий путь
        # наград, что и в HTTP-API — спавн регулярной (один раз, spawned_at),
        # XP/монеты/растение. Выход из done сбрасывает done_at.
        outcome = None
        if task.status == "done" and task.done_at is None:
            task.done_at = utcnow()
            outcome = handle_task_closed(session, task)
        elif task.status != "done" and task.done_at is not None:
            task.done_at = None
        session.commit()
        publish(None, "task.changed", {"id": task.id, "source": "mcp"})
        if outcome is not None and outcome.event is not None:
            # Скромный тост в открытых вкладках — закрытие тоже праздник (ТЗ 3.14)
            publish(
                None,
                "xp.changed",
                {
                    "amount": outcome.event.amount,
                    "rarity": outcome.event.rarity,
                    "celebrate": True,
                },
            )
        result = _compact(task)
        if outcome is not None and outcome.spawned is not None:
            result["spawned_next_id"] = outcome.spawned.id
        return result
    finally:
        session.close()


def complete_task(
    task_id: Annotated[int, Field(description="id задачи (из list_tasks/get_task)")],
    actual_minutes: Annotated[
        int | None, Field(description="Фактически потраченное время в минутах")
    ] = None,
) -> dict[str, Any]:
    """Завершить задачу (псевдоним update_task со status="done").

    Награды и спавн следующего экземпляра регулярной задачи выполняются
    автоматически; повторный вызов дублей не создаёт.
    """
    return update_task(task_id, status="done", actual_minutes=actual_minutes)


def list_tasks(
    status: Annotated[
        str | None, Field(description="Фильтр: to_do | in_progress | done | cancelled | deferred")
    ] = None,
    project_id: Annotated[
        int | None, Field(description="Фильтр по id проекта (из list_projects)")
    ] = None,
    project_name: Annotated[
        str | None, Field(description="Фильтр по имени проекта (регистронезависимо)")
    ] = None,
    tag_id: Annotated[int | None, Field(description="Фильтр по id тега (из list_tags)")] = None,
    detail_state: Annotated[
        str | None, Field(description="raw (стек входящих) | approved (разобрано)")
    ] = None,
    query: Annotated[
        str | None, Field(description="Подстрока по заголовку и описанию задачи")
    ] = None,
    limit: Annotated[
        int, Field(description="Максимум задач в ответе (по умолчанию 50, до 200)")
    ] = 50,
) -> list[dict[str, Any]]:
    """Найти задачи: фильтры по статусу, проекту, тегу и подстроке.

    Каждая задача в ответе: id, title, status, project, tags и сроки. Работайте
    с найденной задачей по её id (update_task / get_task / complete_task).
    """
    session = get_session_factory()()
    try:
        stmt = select(Task).order_by(Task.created_at.desc()).limit(min(limit, 200))
        if status:
            stmt = stmt.where(Task.status == status)
        if project_id or project_name:
            project = _resolve_project(session, project_id, project_name)
            assert project is not None  # задан хотя бы один фильтр проекта
            stmt = stmt.where(Task.project_id == project.id)
        if detail_state:
            stmt = stmt.where(Task.detail_state == detail_state)
        if tag_id:
            stmt = stmt.where(Task.tags.any(Tag.id == tag_id))
        if query:
            pattern = f"%{query}%"
            # Описание живёт в документе — ищем join'ом по полиморфной привязке
            stmt = stmt.outerjoin(
                Document,
                and_(Document.owner_id == Task.id, Document.owner_type == "task"),
            )
            stmt = stmt.where(Task.title.ilike(pattern) | Document.body.ilike(pattern))
        return [_compact(t) for t in session.scalars(stmt).all()]
    finally:
        session.close()


def get_task(
    task_id: Annotated[int, Field(description="id задачи (из list_tasks)")],
) -> dict[str, Any]:
    """Полное описание задачи: описание, время, бюджет, вложения.

    Ошибка «not found» означает неверный id — найдите верный через
    list_tasks(query=...).
    """
    session = get_session_factory()()
    try:
        return _full(_get_task(session, task_id), session)
    finally:
        session.close()


def list_projects(
    include_archived: Annotated[bool, Field(description="Включая проекты в архиве")] = False,
) -> list[dict[str, Any]]:
    """Список проектов: id, имя, заметка (сокращённая), число открытых задач.

    Вызывайте ПЕРЕД созданием задач: выберите подходящий проект и передавайте
    его project_id (или project_name) в create_task/update_task — так задачи
    остаются в контексте своего проекта.
    """
    session = get_session_factory()()
    try:
        stmt = select(Project).order_by(Project.name)
        if not include_archived:
            stmt = stmt.where(Project.is_archived.is_(False))
        return [_project_compact(p, session) for p in session.scalars(stmt).all()]
    finally:
        session.close()


def get_project(
    project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None,
    name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None,
) -> dict[str, Any]:
    """Контекст проекта: заметка (цели, договорённости) и открытые задачи.

    Вызывайте перед обсуждением задач проекта и после перерыва в разговоре —
    заметка и открытые задачи восстановят контекст.
    """
    session = get_session_factory()()
    try:
        project = _resolve_project(session, project_id, name)
        if project is None:
            raise ValueError("Pass project_id or name — see list_projects().")
        tasks = (
            session.scalars(
                select(Task)
                .where(
                    Task.project_id == project.id,
                    Task.status.in_(("to_do", "in_progress")),
                )
                .order_by(Task.created_at.desc())
                .limit(100)
            )
            .unique()
            .all()
        )
        data = _project_compact(project, session)
        data["note"] = project.note  # полная заметка, без обрезки списка
        data["tasks"] = [_compact(t) for t in tasks]
        return data
    finally:
        session.close()


def list_tags() -> list[dict[str, Any]]:
    """Справочник тегов: id и имя — для параметра tag_ids."""
    session = get_session_factory()()
    try:
        return [
            {"id": t.id, "name": t.name}
            for t in session.scalars(select(Tag).order_by(Tag.name)).all()
        ]
    finally:
        session.close()


# --- регистрация в MCP (сигнатуры — документация для агента) ---

mcp.tool()(list_projects)
mcp.tool()(get_project)
mcp.tool()(create_task)
mcp.tool()(update_task)
mcp.tool()(complete_task)
mcp.tool()(list_tasks)
mcp.tool()(get_task)
mcp.tool()(list_tags)