"""MCP-сервер (M5, ТЗ 3.10): инструменты для ИИ-агентов.
Заточен под небольшие модели: подробные описания тулов и параметров,
адресация проекта по имени, ошибки с подсказкой следующего шага. Агент
действует от имени пользователя: bearer-токен (per-user, ТЗ 3.10) проверяет
McpAuthMiddleware в main.py и кладёт user_id в request.state.gntodo_user_id;
тул получает его через Context (параметр ctx исключён из схемы для агента).
Инструменты создают свои сессии к БД (вне HTTP-зависимостей FastAPI).
"""
import threading
from datetime import date
from typing import Annotated, Any, cast
from urllib.parse import urlparse
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field
from sqlalchemy import and_, func, select
from app.actor import Actor, actor_from_request
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,
normalize_color,
normalize_repository_url,
normalize_site_url,
)
from app.services import claims, tasklog
from app.services.closing import handle_task_closed
from app.services.detailing import detail_task, summarize_project
from app.services.xp import coins_for_create, grant_create_xp
VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"}
# Подсказка к отказу по мандату (ТЗ 3.20): маленькая модель должна понять, что
# делать дальше — попросить владельца пометить задачу или действовать за него
_MANDATE_HINT = (
" — ask the owner to mark the task as available for AI agents "
"(ai_eligible), or pass is_user=true if you act for the owner"
)
# Подсказка к отказу во взятии в работу: брать задачу агент может только из
# списка свободных, флаг ставит владелец (см. _mandate_failure)
_CLAIM_HINT = (
" — take a task from list_available_tasks; only the owner marks a task "
"as available for AI agents (ai_eligible)"
)
def _mcp_user(ctx: Context[Any, Any, Any] | None) -> str:
"""user_id владельца токена (кладёт McpAuthMiddleware)."""
if ctx is None or ctx.request_context is None:
raise ValueError("MCP context is unavailable — call tools through the MCP server.")
request = ctx.request_context.request
user_id = getattr(request.state, "gntodo_user_id", None) if request is not None else None
if not user_id:
raise ValueError("MCP authentication failed: no user bound to this request.")
return str(user_id)
def _mcp_actor(ctx: Context[Any, Any, Any] | None, is_user: bool = False) -> Actor:
"""Актор действия: по умолчанию агент, is_user=True — «от имени владельца» (ТЗ 3.20).
Заголовок `X-Actor` здесь не читается: у MCP своё объявление — аргумент тула.
"""
request = ctx.request_context.request if ctx is not None and ctx.request_context else None
actor = actor_from_request(request)
return actor.declaring_user() if is_user else actor
# Серверные инструкции — агент видит их до вызова любого тула (ТЗ 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_available_tasks() покажет доступные, claim_task(task_id) возьмёт
её (аренда на 2 часа — пока она жива, задачу не тронет другой агент), дальше
работа и complete_task с комментарием; владелец примет работу или вернёт её.
Работа затягивается — повторяйте claim_task: это продление аренды. Задача не по
силам — release_task(task_id, reason=...), и она снова свободна.
Задачу без пометки владельца и задачу, взятую другим агентом, не трогайте:
ошибка скажет, что делать. Действовать от имени владельца (его поручение, не
своё) можно, передав is_user=true.
ПОИСК
list_tasks(query="подстрока", project_id=..., status=...) ищет по заголовку и
описанию; get_task(task_id) даёт полные данные. Найдя задачу, работайте с ней
по её id.
УПРАВЛЕНИЕ ПРОЕКТАМИ
create_project(name, note, priority, color, site_url, repository_url) — новый проект;
update_project — переименование, заметка, relevance_status ("active" |
"paused"), priority, color, site_url, repository_url, pinned. color — цвет-метка
проекта в HEX #rrggbb, site_url — сайт проекта (http(s)://...), по нему в
интерфейсе подтягивается иконка сайта; repository_url — репозиторий проекта
(http(s)://...), из него в интерфейсе подтягивается README вторым описанием.
pinned=true закрепляет проект, pinned=false открепляет — закреплённые идут
первыми в списке проектов. color, site_url и repository_url необязательны;
пустая строка ("") в update_project убирает значение. Архив вместо
удаления: archive_project прячет проект вместе с задачами из активных
(история сохраняется), restore_project возвращает. Безвозвратного удаления
проектов в MCP нет намеренно.
"""
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,
# Мандат агента (ТЗ 3.20): по ai_eligible агент решает, можно ли трогать
# задачу, accept_state показывает, ждёт ли работа владельца, claimed_by —
# занята ли она другим агентом (протухшая аренда наружу не показывается)
"ai_eligible": task.ai_eligible,
"created_by_kind": task.created_by_kind,
"created_by_name": task.created_by_name,
"accept_state": task.accept_state,
"claimed_by": task.claimed_by,
}
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, user_id: str) -> Task:
task = cast(
Task | None, db.scalar(select(Task).where(Task.id == task_id, Task.user_id == user_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, user_id: str, 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.scalar(select(Project).where(Project.id == project_id, Project.user_id == user_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).where(Project.user_id == user_id)).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,
"pinned": project.pinned,
"color": project.color,
"site_url": project.site_url,
"repository_url": project.repository_url,
"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,
ai_eligible: Annotated[
bool,
Field(
description=(
"true — задача доступна ИИ-агенту (владелец разрешил её взять). "
"Ставьте true, только если владелец просил"
)
),
] = False,
is_user: Annotated[
bool, Field(description="true — действую от имени владельца, а не как агент")
] = False,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Создать задачу; достаточно title — остальное дописывается через update_task.
По возможности привяжите задачу к проекту (project_id или project_name) —
задача без проекта теряется. Автодетализация подставит метаданные в фоне.
Ответ содержит поле project — проверьте привязку. Задача, созданная агентом,
помечается в журнале и в интерфейсе (бейдж «создано агентом»).
"""
user_id = _mcp_user(ctx)
actor = _mcp_actor(ctx, is_user)
session = get_session_factory()()
try:
if parent_task_id is not None:
_get_task(session, parent_task_id, user_id)
task = Task(
user_id=user_id,
title=title,
description=description,
parent_task_id=parent_task_id,
priority=priority,
# Доступность агенту (ТЗ 3.20): по умолчанию нет — мандат выдаёт владелец
ai_eligible=ai_eligible,
created_by_kind=actor.kind,
created_by_name=actor.name or None,
)
project = _resolve_project(session, user_id, 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), Tag.user_id == user_id)
).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()
tasklog.log_event(session, task, tasklog.KIND_CREATED, actor)
# Микронаграда за создание (ТЗ 3.13) — как в HTTP-API
grant_create_xp(session, "create_task", user_id)
session.add(
CoinEvent(user_id=user_id, source="create_task", amount=coins_for_create("create_task"))
)
session.commit()
# Агент меняет данные владельца токена — уведомляем его вкладки (ТЗ 3.14)
publish(user_id, "task.changed", {"id": task.id, "source": "mcp"})
publish(user_id, "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,
ai_eligible: Annotated[
bool | None,
Field(
description=(
"Доступность задачи ИИ-агенту: true — разрешить агентам брать её "
"в работу, false — запретить. Ставит только владелец задачи"
)
),
] = None,
is_user: Annotated[
bool, Field(description="true — действую от имени владельца, а не как агент")
] = False,
comment: Annotated[
str | None,
Field(description="Комментарий к закрытию: что сделано (обязателен агенту)"),
] = None,
ctx: Context[Any, Any, Any] | None = 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") начисляет награды и создаёт
следующий экземпляр регулярной задачи автоматически. Закрывая задачу,
передайте comment — что сделано: владелец увидит его в журнале и при приёмке
работы. Ответ содержит поле project — проверяйте, что задача осталась в
нужном проекте.
"""
user_id = _mcp_user(ctx)
actor = _mcp_actor(ctx, is_user)
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, Task.user_id == user_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=...)."
)
old_status = task.status
old_eligible = task.ai_eligible
# Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг
# ставит только владелец; занятую чужим агентом задачу агент не трогает —
# иначе он закрыл бы работу того, кто её уже делает. Владельцу рамка не мешает
try:
if ai_eligible is not None:
claims.ensure_owner_sets_eligible(actor)
claims.ensure_agent_can_touch(task, actor)
except claims.MandateError as exc:
raise _mandate_failure(exc, _MANDATE_HINT) from None
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, user_id, 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, user_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), Tag.user_id == user_id)
).all()
)
if len(tags) != len(set(tag_ids)):
raise ValueError("Unknown tag id in tag_ids")
task.tags = tags
if ai_eligible is not None:
task.ai_eligible = ai_eligible
# Закрытие — переход в 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
# Журнал (ТЗ 3.20) — тот же, что у HTTP-API: кто и когда закрыл задачу
if status is not None and task.status != old_status:
if task.status == "done" and old_status != "done":
tasklog.log_event(session, task, tasklog.KIND_COMPLETED, actor, comment)
else:
tasklog.log_event(
session,
task,
tasklog.KIND_STATUS,
actor,
tasklog.status_transition(old_status, task.status),
)
if ai_eligible is not None and task.ai_eligible != old_eligible:
tasklog.log_event(
session, task, tasklog.KIND_ELIGIBLE, actor, "on" if task.ai_eligible else "off"
)
# Аренда (ТЗ 3.20): действие владельца и выход из работы её снимают
if not actor.is_agent or task.status not in ("to_do", "in_progress"):
claims.clear_claim(task)
session.commit()
publish(user_id, "task.changed", {"id": task.id, "source": "mcp"})
if outcome is not None and outcome.event is not None:
# Скромный тост в открытых вкладках — закрытие тоже праздник (ТЗ 3.14)
publish(
user_id,
"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)")],
comment: Annotated[
str, Field(description="Что сделано по задаче (обязателен агенту)")
] = "",
actual_minutes: Annotated[
int | None, Field(description="Фактически потраченное время в минутах")
] = None,
is_user: Annotated[
bool, Field(description="true — действую от имени владельца, а не как агент")
] = False,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Завершить задачу (псевдоним update_task со status="done").
Обязательно расскажите в comment, что сделано: агент закрывает задачу с
отчётом, иначе владельцу нечего принимать. Награды и спавн следующего
экземпляра регулярной задачи выполняются автоматически; повторный вызов
дублей не создаёт.
"""
return update_task(
task_id,
status="done",
actual_minutes=actual_minutes,
comment=comment or None,
is_user=is_user,
ctx=ctx,
)
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,
ai_eligible: Annotated[
bool | None, Field(description="Фильтр: true — задачи, доступные ИИ-агенту")
] = None,
created_by_kind: Annotated[
str | None, Field(description="Фильтр: user (создал владелец) | agent (создал агент)")
] = None,
accept_state: Annotated[
str | None,
Field(description="Фильтр приёмки: pending (ждёт владельца) | accepted | rejected"),
] = None,
limit: Annotated[
int, Field(description="Максимум задач в ответе (по умолчанию 50, до 200)")
] = 50,
ctx: Context[Any, Any, Any] | None = None,
) -> list[dict[str, Any]]:
"""Найти задачи: фильтры по статусу, проекту, тегу и подстроке.
Каждая задача в ответе: id, title, status, project, tags и сроки. Работайте
с найденной задачей по её id (update_task / get_task / complete_task).
Отдельный список задач, доступных агенту, даёт list_available_tasks.
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
stmt = (
select(Task)
.where(Task.user_id == user_id)
.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, user_id, 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(and_(Tag.id == tag_id, Tag.user_id == user_id)))
if ai_eligible is not None:
stmt = stmt.where(Task.ai_eligible.is_(ai_eligible))
if created_by_kind:
stmt = stmt.where(Task.created_by_kind == created_by_kind)
if accept_state:
stmt = stmt.where(Task.accept_state == accept_state)
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)")],
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Полное описание задачи: описание, время, бюджет, вложения.
Ошибка «not found» означает неверный id — найдите верный через
list_tasks(query=...).
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
return _full(_get_task(session, task_id, user_id), session)
finally:
session.close()
def _available(task: Task, actor: Actor) -> dict[str, Any]:
"""Свободная задача глазами агента: сжатая карточка плюс срок аренды."""
data = _compact(task)
data["estimated_minutes"] = task.estimated_minutes
deadline = task.claim_deadline
data["claim_expires_at"] = deadline.isoformat() if deadline else None
data["claimed_by_me"] = task.claim_is_alive and task.claimed_by_token_id == actor.token_id
return data
def _get_task_locked(db: Any, task_id: int, user_id: str) -> Task:
"""Задача с блокировкой строки: взятие в работу неделимо (ТЗ 3.20).
В SQLite (тесты) FOR UPDATE игнорируется — гонку двух агентов ловит только
Postgres; правило «аренда одна» проверяет claims.claim_task.
"""
task = cast(
Task | None,
db.scalar(
select(Task)
.where(Task.id == task_id, Task.user_id == user_id)
.with_for_update(of=Task)
),
)
if task is None:
raise ValueError(
f"Task {task_id} not found — find the right id with list_available_tasks."
)
return task
def _mandate_failure(exc: claims.MandateError, hint: str) -> ValueError:
"""Отказ по мандату с подсказкой следующего шага (ТЗ 3.10).
Чужая живая аренда — не «нельзя», а «занято»: подсказка у неё своя, иначе
агент пошёл бы выпрашивать флаг у владельца вместо другой задачи.
"""
busy = (
" — another agent is working on it; take another task from list_available_tasks, "
"or repeat claim_task after the lease expires (2 hours)"
)
return ValueError(f"{exc}{busy if isinstance(exc, claims.LeaseBusyError) else hint}")
def list_available_tasks(ctx: Context[Any, Any, Any] | None = None) -> list[dict[str, Any]]:
"""Задачи, которые вы вправе взять в работу прямо сейчас (ТЗ 3.20).
Только помеченные владельцем (ai_eligible), не начатые и не занятые другим
агентом. Здесь же остаются задачи, уже взятые вами: у них виден срок аренды
(claim_expires_at) — продлевайте её claim_task, если работа затягивается.
"""
user_id = _mcp_user(ctx)
actor = _mcp_actor(ctx)
session = get_session_factory()()
try:
rows = session.scalars(
select(Task)
.where(Task.user_id == user_id, claims.available_to_agent(actor))
.order_by(Task.priority.desc().nullslast(), Task.created_at)
).all()
return [_available(t, actor) for t in rows]
finally:
session.close()
def claim_task(
task_id: Annotated[int, Field(description="id задачи из list_available_tasks")],
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Взять задачу в работу: аренда на два часа (ТЗ 3.20).
Пока аренда жива, задачу не тронет другой агент. Повторный вызов — продление
аренды (heartbeat): вызывайте его, пока работаете, если она занимает больше
двух часов (в журнал продление не пишется). Закончив, закройте задачу через
complete_task с комментарием — владелец примет работу. Если задача оказалась
не по силам, верните её через release_task: она снова станет свободной.
"""
user_id = _mcp_user(ctx)
actor = _mcp_actor(ctx)
session = get_session_factory()()
try:
task = _get_task_locked(session, task_id, user_id)
try:
fresh = claims.claim_task(task, actor)
except claims.MandateError as exc:
raise _mandate_failure(exc, _CLAIM_HINT) from None
if fresh:
tasklog.log_event(session, task, tasklog.KIND_CLAIMED, actor)
session.commit()
result = _available(task, actor)
result["renewed"] = not fresh
return result
finally:
session.close()
def release_task(
task_id: Annotated[int, Field(description="id задачи, взятой через claim_task")],
reason: Annotated[
str, Field(description="Почему возвращаете задачу — уйдёт в журнал владельцу")
] = "",
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Вернуть задачу в свободные: она оказалась вам не по силам (ТЗ 3.20).
Возврат — не отказ навсегда: задача снова появится в list_available_tasks и
её сможет взять другой агент. Причину пишите в reason — владелец увидит её в
журнале.
"""
user_id = _mcp_user(ctx)
actor = _mcp_actor(ctx)
session = get_session_factory()()
try:
task = _get_task_locked(session, task_id, user_id)
try:
had_lease = claims.release_claim(task, actor)
except claims.MandateError as exc:
raise _mandate_failure(exc, _CLAIM_HINT) from None
if had_lease:
tasklog.log_event(session, task, tasklog.KIND_RELEASED, actor, reason or None)
session.commit()
result = _available(task, actor)
result["released"] = had_lease
return result
finally:
session.close()
def list_projects(
include_archived: Annotated[bool, Field(description="Включая проекты в архиве")] = False,
ctx: Context[Any, Any, Any] | None = None,
) -> list[dict[str, Any]]:
"""Список проектов: id, имя, заметка (сокращённая), число открытых задач.
Закреплённые проекты (pinned) идут первыми — как в списке проектов в UI.
Вызывайте ПЕРЕД созданием задач: выберите подходящий проект и передавайте
его project_id (или project_name) в create_task/update_task — так задачи
остаются в контексте своего проекта.
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
stmt = (
select(Project)
.where(Project.user_id == user_id)
# закреплённые первыми (0.66) — как в списке проектов в UI
.order_by(Project.pinned.desc(), 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,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Контекст проекта: заметка (цели, договорённости) и открытые задачи.
Вызывайте перед обсуждением задач проекта и после перерыва в разговоре —
заметка и открытые задачи восстановят контекст.
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
project = _resolve_project(session, user_id, project_id, name)
if project is None:
raise ValueError("Pass project_id or name — see list_projects().")
tasks = (
session.scalars(
select(Task)
.where(
Task.user_id == user_id,
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(ctx: Context[Any, Any, Any] | None = None) -> list[dict[str, Any]]:
"""Справочник тегов: id и имя — для параметра tag_ids."""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
return [
{"id": t.id, "name": t.name}
for t in session.scalars(
select(Tag).where(Tag.user_id == user_id).order_by(Tag.name)
).all()
]
finally:
session.close()
# --- управление проектами (ТЗ 3.10): архив вместо удаления ---
def create_project(
name: Annotated[str, Field(description="Название проекта (уникально)")],
note: Annotated[
str | None,
Field(description="Заметка проекта: цель, договорённости, ссылки (markdown)"),
] = None,
priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None,
color: Annotated[
str | None,
Field(description="Цвет-метка проекта: HEX #rrggbb (например #7aa2f7)"),
] = None,
site_url: Annotated[
str | None,
Field(description="Сайт проекта, http(s)://... — по нему подтягивается favicon"),
] = None,
repository_url: Annotated[
str | None,
Field(description="Репозиторий проекта, http(s)://... — из него подтягивается README"),
] = None,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Создать проект — контейнер задач со своей заметкой.
Микронаграда (XP, монеты) начисляется автоматически. Название должно быть
уникальным: если не уверены, что проекта ещё нет, сначала list_projects().
Создав проект, привязывайте к нему задачи (project_id/project_name).
"""
user_id = _mcp_user(ctx)
if not name.strip():
raise ValueError("name cannot be empty")
# нормализаторы сами объясняют формат (color=#rrggbb, site_url=http(s)://host/...)
color = normalize_color(color)
site_url = normalize_site_url(site_url)
repository_url = normalize_repository_url(repository_url)
session = get_session_factory()()
try:
exists = cast(
Project | None,
session.scalar(select(Project).where(Project.name == name, Project.user_id == user_id)),
)
if exists is not None:
raise ValueError(
f"Project '{name}' already exists (id {exists.id}) — pass project_id=... "
"to create_task/update_task instead of creating a duplicate."
)
project = Project(
user_id=user_id,
name=name,
note=note or "",
priority=priority,
color=color,
site_url=site_url,
repository_url=repository_url,
)
session.add(project)
session.flush()
grant_create_xp(session, "create_project", user_id)
session.add(
CoinEvent(
user_id=user_id, source="create_project", amount=coins_for_create("create_project")
)
)
session.commit()
if (note or "").strip():
threading.Thread(target=summarize_project, args=(project.id,), daemon=True).start()
publish(user_id, "project.changed", {"id": project.id})
publish(user_id, "xp.changed", {"celebrate": False})
return _project_compact(project, session)
finally:
session.close()
def update_project(
project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None,
name: Annotated[
str | None, Field(description="Текущее имя проекта, если id неизвестен")
] = None,
new_name: Annotated[str | None, Field(description="Новое название проекта")] = None,
note: Annotated[str | None, Field(description="Новая заметка проекта, markdown")] = None,
relevance_status: Annotated[
str | None, Field(description="active (в работе) | paused (приостановлен)")
] = None,
priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None,
color: Annotated[
str | None,
Field(description="Новый цвет-метка проекта, HEX #rrggbb; пустая строка '' — убрать"),
] = None,
site_url: Annotated[
str | None,
Field(description="Новый сайт проекта, http(s)://...; пустая строка '' — убрать"),
] = None,
repository_url: Annotated[
str | None,
Field(
description="Новый репозиторий проекта, http(s)://...; пустая строка '' — убрать"
),
] = None,
pinned: Annotated[
bool | None, Field(description="Закрепить проект (true) / открепить (false)")
] = None,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Частично обновить проект — передавайте только нужные поля.
Цвет, сайт и репозиторий: пустая строка их убирает, None (не передан) — не меняет.
Заметка проекта — главный контекст для агента (get_project) и
автодетализации, обновляйте её по итогам договорённостей.
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
project = _resolve_project(session, user_id, project_id, name)
if project is None:
raise ValueError("Pass project_id or name — see list_projects().")
if new_name is not None:
if not new_name.strip():
raise ValueError("new_name cannot be empty")
if new_name != project.name:
exists = cast(
Project | None,
session.scalar(
select(Project).where(Project.name == new_name, Project.user_id == user_id)
),
)
if exists is not None:
raise ValueError(
f"Project name '{new_name}' already exists (id {exists.id}) — "
"pick another name."
)
project.name = new_name
note_changed = note is not None and note != project.note
if note is not None:
project.note = note
if relevance_status is not None:
if relevance_status not in ("active", "paused"):
raise ValueError(
f"Unknown relevance_status: {relevance_status} — use 'active' or 'paused'."
)
project.relevance_status = relevance_status
if priority is not None:
project.priority = priority
if color is not None:
project.color = normalize_color(color)
if site_url is not None:
project.site_url = normalize_site_url(site_url)
if repository_url is not None:
project.repository_url = normalize_repository_url(repository_url)
if pinned is not None:
project.pinned = pinned
session.commit()
if note_changed:
# суммаризация заметки для контекста детализации — не блокирует ответ
threading.Thread(target=summarize_project, args=(project.id,), daemon=True).start()
publish(user_id, "project.changed", {"id": project.id})
return _project_compact(project, session)
finally:
session.close()
def archive_project(
project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None,
name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Переместить проект в архив вместе со всеми задачами — НЕ удаление.
Проект исчезает из активных списков, но история и задачи сохраняются;
вернуть можно restore_project(). Повторный вызов безвреден.
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
project = _resolve_project(session, user_id, project_id, name)
if project is None:
raise ValueError("Pass project_id or name — see list_projects().")
if not project.is_archived:
project.is_archived = True
session.commit()
publish(user_id, "project.changed", {"id": project.id})
return _project_compact(project, session)
finally:
session.close()
def restore_project(
project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None,
name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None,
ctx: Context[Any, Any, Any] | None = None,
) -> dict[str, Any]:
"""Вернуть проект из архива — со всеми задачами и историей.
Архивные проекты ищутся: list_projects(include_archived=True).
"""
user_id = _mcp_user(ctx)
session = get_session_factory()()
try:
project = _resolve_project(session, user_id, project_id, name)
if project is None:
raise ValueError("Pass project_id or name — see list_projects().")
if project.is_archived:
project.is_archived = False
session.commit()
publish(user_id, "project.changed", {"id": project.id})
return _project_compact(project, session)
finally:
session.close()
# --- регистрация в MCP (сигнатуры — документация для агента) ---
mcp.tool()(list_projects)
mcp.tool()(get_project)
mcp.tool()(create_project)
mcp.tool()(update_project)
mcp.tool()(archive_project)
mcp.tool()(restore_project)
mcp.tool()(create_task)
mcp.tool()(update_task)
mcp.tool()(complete_task)
mcp.tool()(list_tasks)
mcp.tool()(get_task)
mcp.tool()(list_available_tasks)
mcp.tool()(claim_task)
mcp.tool()(release_task)
mcp.tool()(list_tags)