feat(mcp): project + milestone CRUD tools

Seven tools matching existing fable-mcp contracts:
  - fable_list/get/create/update_project (no delete; archive via status)
  - fable_list/create/update_milestone (no get; no delete)

LLM-era similarity-check / 'confirmed' guard for create_project is
NOT replicated — Claude doesn't need it. The service's auto-summary
regeneration side effect (services.projects.update_project) stays
for now; gets removed in Phase 7 along with all other LLM code.

Notable sentinels:
  - update_milestone: order_index=-1 means "leave unchanged" (0 is valid)
  - create_milestone: description="" becomes None at the service layer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:24:07 -04:00
parent d086c9b606
commit 4d6bae77b4
5 changed files with 414 additions and 1 deletions
+5 -1
View File
@@ -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)
@@ -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)
+123
View File
@@ -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)
+98
View File
@@ -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")
+93
View File
@@ -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")