"""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)