feat(mcp): task CRUD tools + add_task_log

Five tools wrapping services/notes.py with is_task=True (tasks are
notes with non-null status) plus services/task_logs.create_log for
add_task_log. Matches existing fable-mcp contracts. No delete_task —
preserves existing surface; cancel by updating status to "cancelled".

fable_get_task enriches with parent_title (extra service call when
parent_id is set), matching the existing route's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:22:30 -04:00
parent b026421985
commit d086c9b606
3 changed files with 369 additions and 1 deletions
+2 -1
View File
@@ -4,10 +4,11 @@ Each tool module exposes a `register(mcp)` function that attaches its tools
to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from fabledassistant.mcp.tools import notes, search
from fabledassistant.mcp.tools import notes, search, tasks
def register_all(mcp) -> None:
"""Register every tool module's tools on the given FastMCP instance."""
search.register(mcp)
notes.register(mcp)
tasks.register(mcp)
+172
View File
@@ -0,0 +1,172 @@
"""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 fable_add_task_log.
There is no fable_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 task_logs as task_logs_svc
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable.
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.
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,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
return {"tasks": [n.to_dict() for n in rows], "total": total}
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
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
return data
async def fable_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,
) -> dict:
"""Create a new task in Fable.
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.
"""
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,
)
return note.to_dict()
async def fable_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 Fable 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 fable_add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable 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,
}
def register(mcp) -> None:
for fn in (
fable_list_tasks,
fable_get_task,
fable_create_task,
fable_update_task,
fable_add_task_log,
):
mcp.tool(name=fn.__name__)(fn)
+195
View File
@@ -0,0 +1,195 @@
"""Tests for fable_*_task tools."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.mcp._context import _user_id_ctx
from fabledassistant.mcp.tools.tasks import (
fable_list_tasks, fable_get_task, fable_create_task,
fable_update_task, fable_add_task_log,
)
@pytest.fixture(autouse=True)
def _bind_user():
token = _user_id_ctx.set(7)
yield
_user_id_ctx.reset(token)
def _fake_task(*, parent_id: int | None = None, **overrides) -> MagicMock:
n = MagicMock()
n.parent_id = parent_id
base = {
"id": 1, "title": "t", "body": "", "status": "todo",
"priority": "none", "tags": [], "parent_id": parent_id,
"is_task": True,
}
base.update(overrides)
n.to_dict.return_value = base
n.title = base["title"]
return n
@pytest.mark.asyncio
async def test_list_tasks_passes_is_task_true_and_repackages():
rows = [_fake_task(id=1), _fake_task(id=2)]
mock = AsyncMock(return_value=(rows, 2))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
out = await fable_list_tasks()
assert out["total"] == 2
assert len(out["tasks"]) == 2
assert mock.call_args.kwargs["is_task"] is True
@pytest.mark.asyncio
async def test_list_tasks_status_filter():
mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
await fable_list_tasks(status="in_progress")
assert mock.call_args.kwargs["status"] == "in_progress"
@pytest.mark.asyncio
async def test_list_tasks_empty_status_means_no_filter():
mock = AsyncMock(return_value=([], 0))
with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
await fable_list_tasks(status="")
assert mock.call_args.kwargs["status"] is None
@pytest.mark.asyncio
async def test_get_task_with_no_parent_returns_null_parent_title():
fake = _fake_task(id=5, title="solo", parent_id=None)
with patch(
"fabledassistant.mcp.tools.tasks.notes_svc.get_note",
AsyncMock(return_value=fake),
):
out = await fable_get_task(task_id=5)
assert out["parent_title"] is None
assert out["title"] == "solo"
@pytest.mark.asyncio
async def test_get_task_enriches_with_parent_title():
"""When parent_id is set, fable_get_task fetches the parent and adds parent_title."""
child = _fake_task(id=10, title="child", parent_id=5)
parent = _fake_task(id=5, title="parent of 10", parent_id=None)
# get_note is called twice: once for child, once for parent
mock_get = AsyncMock(side_effect=[child, parent])
with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get):
out = await fable_get_task(task_id=10)
assert out["parent_title"] == "parent of 10"
assert mock_get.await_count == 2
@pytest.mark.asyncio
async def test_get_task_parent_missing_returns_null():
"""If parent_id is set but the parent is gone (orphaned), parent_title is None."""
child = _fake_task(id=10, parent_id=5)
mock_get = AsyncMock(side_effect=[child, None])
with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note", mock_get):
out = await fable_get_task(task_id=10)
assert out["parent_title"] is None
@pytest.mark.asyncio
async def test_get_task_raises_when_not_found():
with patch(
"fabledassistant.mcp.tools.tasks.notes_svc.get_note",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="task 999 not found"):
await fable_get_task(task_id=999)
@pytest.mark.asyncio
async def test_create_task_passes_status():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="do x", status="todo")
assert mock.call_args.kwargs["status"] == "todo"
@pytest.mark.asyncio
async def test_create_task_priority_empty_becomes_none():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="x", priority="")
assert mock.call_args.kwargs["priority"] is None
@pytest.mark.asyncio
async def test_create_task_zero_id_sentinels_become_none():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
await fable_create_task(title="x", project_id=0, milestone_id=0, parent_id=0)
assert mock.call_args.kwargs["project_id"] is None
assert mock.call_args.kwargs["milestone_id"] is None
assert mock.call_args.kwargs["parent_id"] is None
@pytest.mark.asyncio
async def test_update_task_only_sends_non_default_fields():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await fable_update_task(task_id=1, status="done")
args, kwargs = mock.call_args
assert args == (7, 1)
assert kwargs == {"status": "done"}
@pytest.mark.asyncio
async def test_update_task_empty_priority_is_omitted():
"""Priority="" is "leave unchanged" — must not reach service as empty string."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await fable_update_task(task_id=1, status="done", priority="")
assert "priority" not in mock.call_args.kwargs
@pytest.mark.asyncio
async def test_update_task_raises_when_not_found():
with patch(
"fabledassistant.mcp.tools.tasks.notes_svc.update_note",
AsyncMock(return_value=None),
):
with pytest.raises(ValueError, match="task 999 not found"):
await fable_update_task(task_id=999, status="done")
@pytest.mark.asyncio
async def test_add_task_log_returns_log_dict():
log = MagicMock()
log.id = 5
log.task_id = 1
log.content = "checkpoint"
log.created_at = datetime(2026, 5, 26, 12, tzinfo=timezone.utc)
# No to_dict on TaskLog model (per task_logs.py inspection) — fall through to manual dict
del log.to_dict
with patch(
"fabledassistant.mcp.tools.tasks.task_logs_svc.create_log",
AsyncMock(return_value=log),
):
out = await fable_add_task_log(task_id=1, content="checkpoint")
assert out["id"] == 5
assert out["task_id"] == 1
assert out["content"] == "checkpoint"
assert out["created_at"].startswith("2026-05-26")
@pytest.mark.asyncio
async def test_add_task_log_propagates_value_error_when_task_missing():
"""create_log raises ValueError if the task doesn't exist; we let it through."""
with patch(
"fabledassistant.mcp.tools.tasks.task_logs_svc.create_log",
AsyncMock(side_effect=ValueError("Task 999 not found")),
):
with pytest.raises(ValueError):
await fable_add_task_log(task_id=999, content="x")