"""MCP-сервер (M5, ТЗ 3.10): инструменты для ИИ-агентов.
Заточен под небольшие модели: подробные описания тулов и параметров,
адресация проекта по имени, ошибки с подсказкой следующего шага. Агент
действует от имени пользователя; мультитенантности нет. Аутентификация
временно — bearer-токен из конфига (MCP_TOKEN), до интеграции SSO-токенов.
Инструменты создают свои сессии к БД (вне HTTP-зависимостей FastAPI).
"""
import threading
from datetime import date
from typing import Annotated, Any, cast
from urllib.parse import urlparse
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field
from sqlalchemy import and_, func, select
from app.config import get_settings
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.
"""
def _transport_security() -> TransportSecuritySettings:
"""Разрешённый Host для MCP — домен приложения, из redirect URI SSO.
Дефолт FastMCP пропускает только localhost: в dev это работает
(vite-прокси переписывает Host на localhost:8134), а в проде за nginx
Host — реальный домен, и /mcp отвечал бы 421 Misdirected Request.
nginx $host отдаёт Host без порта — разрешаем обе формы; Origin
перечисляем для https/http (браузерные клиенты; у агентов Origin нет).
"""
host = urlparse(get_settings().gauth_redirect_uri).hostname or ""
if host in ("127.0.0.1", "localhost", "::1"):
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[host, f"{host}:*"],
allowed_origins=[
f"https://{host}",
f"https://{host}:*",
f"http://{host}",
f"http://{host}:*",
],
)
# streamable_http_path="/" — чтобы под /mcp основного приложения маршрут
# FastMCP не дублировался (иначе он оказывается на /mcp/mcp)
mcp = FastMCP(
"gntodo",
streamable_http_path="/",
instructions=INSTRUCTIONS,
transport_security=_transport_security(),
)
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)