Files
FabledGrimoire/src/fabledassistant/mcp/tools/tasks.py
T

210 lines
7.2 KiB
Python

"""Task CRUD MCP tools.
Tasks are notes with a non-null `status` — same model, different filter.
Wrappers call services/notes.py for CRUD with is_task=True and add the
task-specific fields (status, priority, due_date, parent_id), plus
services/task_logs.py for add_task_log.
There is no delete_task — matches the existing fable-mcp surface.
Cancel by updating status to "cancelled".
Sentinels (preserved from existing fable-mcp):
- status="" / priority="" / title="" / body="" → "leave unchanged" on update
- status="todo" is the default on create (creates a task; non-null status is
what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
"leave unchanged" on update
"""
from __future__ import annotations
from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import notes as notes_svc
from fabledassistant.services import planning as planning_svc
from fabledassistant.services import rulebooks as rulebooks_svc
from fabledassistant.services import task_logs as task_logs_svc
async def list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
kind: str = "",
) -> dict:
"""List tasks in Scribe.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
Results are ordered by last-updated descending.
"""
uid = current_user_id()
rows, total = await notes_svc.list_notes(
uid,
is_task=True,
status=status or None,
project_id=project_id or None,
task_kind=kind or None,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
return {"tasks": [n.to_dict() for n in rows], "total": total}
async def get_task(task_id: int) -> dict:
"""Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at. For kind=plan
tasks, the response also includes applicable_rules + subscribed_rulebooks
from the task's project's rulebook subscriptions.
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, task_id)
if note is None:
raise ValueError(f"task {task_id} not found")
data = note.to_dict()
parent_title = None
if note.parent_id:
parent = await notes_svc.get_note(uid, note.parent_id)
if parent is not None:
parent_title = parent.title
data["parent_title"] = parent_title
if data.get("task_kind") == "plan" and note.project_id:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid,
)
data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
return data
async def create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
kind: str = "work",
) -> dict:
"""Create a new task in Scribe.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, medium, high, or 'none'. Omit (empty string) to leave unset.
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
kind: 'work' (default) or 'plan'. Prefer the start_planning tool to create plans.
"""
uid = current_user_id()
note = await notes_svc.create_note(
uid,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
task_kind=kind,
)
return note.to_dict()
async def update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: none, low, medium, high.
project_id: New project. Omit (0) to leave unchanged.
milestone_id: New milestone. Omit (0) to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if body:
fields["body"] = body
if status:
fields["status"] = status
if priority:
fields["priority"] = priority
if project_id:
fields["project_id"] = project_id
if milestone_id:
fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
return note.to_dict()
async def add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Scribe task.
Use this to record work sessions, decisions, or status updates over time
without overwriting the task's main body. Each entry is stored separately
and shown chronologically in the task view.
"""
uid = current_user_id()
log = await task_logs_svc.create_log(uid, task_id, content)
return log.to_dict() if hasattr(log, "to_dict") else {
"id": log.id, "task_id": log.task_id, "content": log.content,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
async def start_planning(project_id: int, title: str) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Creates a plan-task (a task with kind=plan) seeded with a plan template under
the given project, and returns it together with the project's applicable
Rulebook rules and brief context. Maintain the plan afterwards with the normal
task tools (update_task to edit the body, add_task_log to record progress).
Args:
project_id: The project this plan is for.
title: A short title for the plan.
"""
uid = current_user_id()
return await planning_svc.start_planning(
user_id=uid, project_id=project_id, title=title,
)
def register(mcp) -> None:
for fn in (
list_tasks,
get_task,
create_task,
update_task,
add_task_log,
start_planning,
):
mcp.tool(name=fn.__name__)(fn)