From d086c9b606ad9718f34637948cd24780eb5a7b70 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 20:22:30 -0400 Subject: [PATCH] feat(mcp): task CRUD tools + add_task_log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/fabledassistant/mcp/tools/__init__.py | 3 +- src/fabledassistant/mcp/tools/tasks.py | 172 +++++++++++++++++++ tests/test_mcp_tool_tasks.py | 195 ++++++++++++++++++++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/fabledassistant/mcp/tools/tasks.py create mode 100644 tests/test_mcp_tool_tasks.py diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index cc45bd2..2ed2a47 100644 --- a/src/fabledassistant/mcp/tools/__init__.py +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -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) diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py new file mode 100644 index 0000000..6a597c5 --- /dev/null +++ b/src/fabledassistant/mcp/tools/tasks.py @@ -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) diff --git a/tests/test_mcp_tool_tasks.py b/tests/test_mcp_tool_tasks.py new file mode 100644 index 0000000..9fb4c15 --- /dev/null +++ b/tests/test_mcp_tool_tasks.py @@ -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")