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

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

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

import threading
from typing import Any, cast

from mcp.server.fastmcp import FastMCP
from sqlalchemy import select

from app.db import get_session_factory
from app.models import Attachment, Project, Tag, Task, utcnow
from app.services.detailing import detail_task

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

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


def _compact(task: Task) -> dict[str, Any]:
    return {
        "id": task.id,
        "title": task.title,
        "status": task.status,
        "detail_state": task.detail_state,
        "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,
    }


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.task_id == task.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")
    return task


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


def create_task(
    title: str,
    description: str = "",
    parent_task_id: int | None = None,
    project_id: int | None = None,
    tag_ids: list[int] | None = None,
    priority: int | None = None,
) -> dict[str, Any]:
    """Создать задачу. Достаточно title — автодетализация запустится в фоне."""
    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,
        )
        if project_id is not None:
            project = cast(Project | None, session.get(Project, project_id))
            if project is None:
                raise ValueError(f"Project {project_id} not found")
            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")
            task.tags = tags
        session.add(task)
        session.commit()
        # Автодетализация — в фоновом потоке (LLM работает десятки секунд)
        threading.Thread(target=detail_task, args=(task.id,), daemon=True).start()
        return {"id": task.id, "title": task.title, "detail_state": task.detail_state}
    finally:
        session.close()


def update_task(
    task_id: int,
    title: str | None = None,
    description: str | None = None,
    status: str | None = None,
    project_id: int | None = None,
    parent_task_id: int | None = None,
    priority: int | None = None,
    estimated_minutes: int | None = None,
    actual_minutes: int | None = None,
    budget_money: int | None = None,
    cost_estimate_money: int | None = None,
    tag_ids: list[int] | None = None,
) -> dict[str, Any]:
    """Частично обновить задачу (передавайте только нужные поля)."""
    session = get_session_factory()()
    try:
        task = _get_task(session, task_id)
        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 status == "done" and task.done_at is None:
                task.done_at = utcnow()
        if project_id is not None:
            if session.get(Project, project_id) is None:
                raise ValueError(f"Project {project_id} not found")
            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
        session.commit()
        return _compact(task)
    finally:
        session.close()


def complete_task(task_id: int, actual_minutes: int | None = None) -> dict[str, Any]:
    """Завершить задачу; actual_minutes — фактическое время в минутах."""
    return update_task(task_id, status="done", actual_minutes=actual_minutes)


def list_tasks(
    status: str | None = None,
    project_id: int | None = None,
    tag_id: int | None = None,
    detail_state: str | None = None,
    query: str | None = None,
    limit: int = 50,
) -> list[dict[str, Any]]:
    """Список/поиск задач: фильтры по статусу, проекту, тегу, подстроке."""
    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:
            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}%"
            stmt = stmt.where(Task.title.ilike(pattern) | Task.description.ilike(pattern))
        return [_compact(t) for t in session.scalars(stmt).all()]
    finally:
        session.close()


def get_task(task_id: int) -> dict[str, Any]:
    """Полное описание задачи, включая вложения."""
    session = get_session_factory()()
    try:
        return _full(_get_task(session, task_id), session)
    finally:
        session.close()


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

mcp.tool()(create_task)
mcp.tool()(update_task)
mcp.tool()(complete_task)
mcp.tool()(list_tasks)
mcp.tool()(get_task)