diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py index 2ed2a47..a885f96 100644 --- a/src/fabledassistant/mcp/tools/__init__.py +++ b/src/fabledassistant/mcp/tools/__init__.py @@ -4,7 +4,9 @@ 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, tasks +from fabledassistant.mcp.tools import ( + milestones, notes, projects, search, tasks, +) def register_all(mcp) -> None: @@ -12,3 +14,5 @@ def register_all(mcp) -> None: search.register(mcp) notes.register(mcp) tasks.register(mcp) + projects.register(mcp) + milestones.register(mcp) diff --git a/src/fabledassistant/mcp/tools/milestones.py b/src/fabledassistant/mcp/tools/milestones.py new file mode 100644 index 0000000..a07fc83 --- /dev/null +++ b/src/fabledassistant/mcp/tools/milestones.py @@ -0,0 +1,95 @@ +"""Milestone CRUD MCP tools — thin wrappers over services/milestones.py. + +Mirrors existing fable-mcp milestone tool contracts: list/create/update. The +existing surface has no fable_get_milestone or fable_delete_milestone — kept +that way for parity. + +Sentinels: + - title="" / description="" / status="" → "leave unchanged" on update + - order_index=-1 → "leave unchanged" on update (0 is a valid order_index) + - status="active" default on create +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import milestones as milestones_svc + + +async def fable_list_milestones(project_id: int) -> dict: + """List milestones for a Fable project, ordered by order_index. + + Returns id, title, description, status (active/done), order_index, + and task counts. + """ + uid = current_user_id() + rows = await milestones_svc.get_project_milestone_summary(uid, project_id) + return {"milestones": rows} + + +async def fable_create_milestone( + project_id: int, + title: str, + description: str = "", + status: str = "active", +) -> dict: + """Create a milestone within a Fable project. + + Args: + project_id: The project this milestone belongs to (required). + title: Milestone name (required). + description: Optional description of what this milestone covers. + status: active (default) or done. + """ + uid = current_user_id() + milestone = await milestones_svc.create_milestone( + uid, + project_id=project_id, + title=title, + description=description or None, + status=status, + ) + return milestone.to_dict() + + +async def fable_update_milestone( + project_id: int, + milestone_id: int, + title: str = "", + description: str = "", + status: str = "", + order_index: int = -1, +) -> dict: + """Update a Fable milestone. Only explicitly provided fields are changed. + + Args: + project_id: Project the milestone belongs to (preserved for API parity; + ownership scoping is enforced by user_id at the service layer). + milestone_id: ID of the milestone to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + status: New status — active or done. + order_index: New display position (0-based). Use -1 to leave unchanged. + """ + uid = current_user_id() + fields: dict = {} + if title: + fields["title"] = title + if description: + fields["description"] = description + if status: + fields["status"] = status + if order_index >= 0: + fields["order_index"] = order_index + milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields) + if milestone is None: + raise ValueError(f"milestone {milestone_id} not found") + return milestone.to_dict() + + +def register(mcp) -> None: + for fn in ( + fable_list_milestones, + fable_create_milestone, + fable_update_milestone, + ): + mcp.tool(name=fn.__name__)(fn) diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py new file mode 100644 index 0000000..18ec62a --- /dev/null +++ b/src/fabledassistant/mcp/tools/projects.py @@ -0,0 +1,123 @@ +"""Project CRUD MCP tools — thin wrappers over services/projects.py. + +Mirrors existing fable-mcp project tool contracts. Note: there is no +fable_delete_project here (matches existing fable-mcp surface). To stop +working on a project, update its status to 'archived'. + +The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py +is intentionally NOT replicated here — Claude is the client, not a weak local +model that needs that guardrail. services.projects.create_project creates +directly with no similarity warning. + +The auto-summary regeneration that services.projects.update_project triggers +async will be removed in Phase 7 (it's an LLM call). The wrapper makes no +assumption either way; once the service-layer side effect is gone, this code +keeps working. +""" +from __future__ import annotations + +from fabledassistant.mcp._context import current_user_id +from fabledassistant.services import milestones as milestones_svc +from fabledassistant.services import projects as projects_svc + + +async def fable_list_projects() -> dict: + """List all Fable projects for the current user. + + Returns id, title, description, goal, status (active/archived), color, + and a short auto-generated summary for each project. + """ + uid = current_user_id() + rows = await projects_svc.list_projects(uid) + return {"projects": [p.to_dict() for p in rows]} + + +async def fable_get_project(project_id: int) -> dict: + """Fetch a Fable project by ID, including its milestone summary. + + Returns full project fields plus a milestone_summary list with each + milestone's id, title, status, and task counts. + """ + uid = current_user_id() + project = await projects_svc.get_project(uid, project_id) + if project is None: + raise ValueError(f"project {project_id} not found") + data = project.to_dict() + data["milestone_summary"] = await milestones_svc.get_project_milestone_summary( + uid, project_id, + ) + return data + + +async def fable_create_project( + title: str, + description: str = "", + goal: str = "", + status: str = "active", + color: str = "", +) -> dict: + """Create a new project in Fable. + + Args: + title: Project name (required). + description: Short summary of what the project is. + goal: The desired outcome or definition of done for the project. + status: active (default) or archived. + color: Optional hex colour for the project card (e.g. "#6366f1"). + """ + uid = current_user_id() + project = await projects_svc.create_project( + uid, + title=title, + description=description, + goal=goal, + status=status, + color=color or None, + ) + return project.to_dict() + + +async def fable_update_project( + project_id: int, + title: str = "", + description: str = "", + goal: str = "", + status: str = "", + color: str = "", +) -> dict: + """Update an existing Fable project. Only explicitly provided fields are changed. + + Args: + project_id: ID of the project to update. + title: New title, or omit to leave unchanged. + description: New description, or omit to leave unchanged. + goal: New goal/definition-of-done, or omit to leave unchanged. + status: New status — active or archived. + color: New hex colour, or omit to leave unchanged. + """ + uid = current_user_id() + fields: dict = {} + if title: + fields["title"] = title + if description: + fields["description"] = description + if goal: + fields["goal"] = goal + if status: + fields["status"] = status + if color: + fields["color"] = color + project = await projects_svc.update_project(uid, project_id, **fields) + if project is None: + raise ValueError(f"project {project_id} not found") + return project.to_dict() + + +def register(mcp) -> None: + for fn in ( + fable_list_projects, + fable_get_project, + fable_create_project, + fable_update_project, + ): + mcp.tool(name=fn.__name__)(fn) diff --git a/tests/test_mcp_tool_milestones.py b/tests/test_mcp_tool_milestones.py new file mode 100644 index 0000000..7c2c1f0 --- /dev/null +++ b/tests/test_mcp_tool_milestones.py @@ -0,0 +1,98 @@ +"""Tests for fable_*_milestone tools.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.milestones import ( + fable_list_milestones, fable_create_milestone, fable_update_milestone, +) + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_ms(**overrides) -> MagicMock: + m = MagicMock() + base = {"id": 1, "project_id": 1, "title": "MS", "description": None, + "status": "active", "order_index": 0} + base.update(overrides) + m.to_dict.return_value = base + return m + + +@pytest.mark.asyncio +async def test_list_milestones_returns_dict_with_progress(): + rows = [{"id": 1, "title": "MS1", "status": "active", "task_count": 2}] + with patch( + "fabledassistant.mcp.tools.milestones.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=rows), + ): + out = await fable_list_milestones(project_id=1) + assert out["milestones"] == rows + + +@pytest.mark.asyncio +async def test_create_milestone_passes_through(): + m = _fake_ms(id=5) + mock = AsyncMock(return_value=m) + with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock): + out = await fable_create_milestone(project_id=1, title="new", description="d") + assert out["id"] == 5 + assert mock.call_args.kwargs["project_id"] == 1 + assert mock.call_args.kwargs["title"] == "new" + assert mock.call_args.kwargs["description"] == "d" + + +@pytest.mark.asyncio +async def test_create_milestone_empty_description_becomes_none(): + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("fabledassistant.mcp.tools.milestones.milestones_svc.create_milestone", mock): + await fable_create_milestone(project_id=1, title="t", description="") + assert mock.call_args.kwargs["description"] is None + + +@pytest.mark.asyncio +async def test_update_milestone_only_sends_non_default_fields(): + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): + await fable_update_milestone(project_id=1, milestone_id=5, status="done") + args, kwargs = mock.call_args + assert args == (7, 5) + assert kwargs == {"status": "done"} + + +@pytest.mark.asyncio +async def test_update_milestone_order_index_negative_is_omitted(): + """order_index=-1 sentinel means leave unchanged.""" + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): + await fable_update_milestone(project_id=1, milestone_id=5, order_index=-1) + assert "order_index" not in mock.call_args.kwargs + + +@pytest.mark.asyncio +async def test_update_milestone_order_index_zero_is_explicit(): + """order_index=0 is a real value (top of list), not a sentinel.""" + m = _fake_ms() + mock = AsyncMock(return_value=m) + with patch("fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", mock): + await fable_update_milestone(project_id=1, milestone_id=5, order_index=0) + assert mock.call_args.kwargs["order_index"] == 0 + + +@pytest.mark.asyncio +async def test_update_milestone_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.milestones.milestones_svc.update_milestone", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="milestone 999 not found"): + await fable_update_milestone(project_id=1, milestone_id=999, title="x") diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py new file mode 100644 index 0000000..9cbad8d --- /dev/null +++ b/tests/test_mcp_tool_projects.py @@ -0,0 +1,93 @@ +"""Tests for fable_*_project tools.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from fabledassistant.mcp._context import _user_id_ctx +from fabledassistant.mcp.tools.projects import ( + fable_list_projects, fable_get_project, fable_create_project, + fable_update_project, +) + + +@pytest.fixture(autouse=True) +def _bind_user(): + token = _user_id_ctx.set(7) + yield + _user_id_ctx.reset(token) + + +def _fake_project(**overrides) -> MagicMock: + p = MagicMock() + base = {"id": 1, "title": "P", "description": "", "goal": "", + "status": "active", "color": None, "auto_summary": None} + base.update(overrides) + p.to_dict.return_value = base + return p + + +@pytest.mark.asyncio +async def test_list_projects_wraps_in_dict(): + rows = [_fake_project(id=1), _fake_project(id=2)] + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.list_projects", + AsyncMock(return_value=rows), + ): + out = await fable_list_projects() + assert len(out["projects"]) == 2 + + +@pytest.mark.asyncio +async def test_get_project_enriches_with_milestone_summary(): + p = _fake_project(id=5, title="found") + milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=p), + ), patch( + "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=milestone_summary), + ): + out = await fable_get_project(project_id=5) + assert out["id"] == 5 + assert out["milestone_summary"] == milestone_summary + + +@pytest.mark.asyncio +async def test_get_project_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="project 999 not found"): + await fable_get_project(project_id=999) + + +@pytest.mark.asyncio +async def test_create_project_passes_color_empty_as_none(): + p = _fake_project() + mock = AsyncMock(return_value=p) + with patch("fabledassistant.mcp.tools.projects.projects_svc.create_project", mock): + await fable_create_project(title="P", color="") + assert mock.call_args.kwargs["color"] is None + + +@pytest.mark.asyncio +async def test_update_project_only_sends_non_default_fields(): + p = _fake_project() + mock = AsyncMock(return_value=p) + with patch("fabledassistant.mcp.tools.projects.projects_svc.update_project", mock): + await fable_update_project(project_id=1, status="archived") + args, kwargs = mock.call_args + assert args == (7, 1) + assert kwargs == {"status": "archived"} + + +@pytest.mark.asyncio +async def test_update_project_raises_when_not_found(): + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.update_project", + AsyncMock(return_value=None), + ): + with pytest.raises(ValueError, match="project 999 not found"): + await fable_update_project(project_id=999, title="x")