"""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 from fabledassistant.services import trash as trash_svc async def list_milestones(project_id: int) -> dict: """List milestones for a Scribe 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 create_milestone( project_id: int, title: str, description: str = "", status: str = "active", ) -> dict: """Create a milestone within a Scribe 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 update_milestone( project_id: int, milestone_id: int, title: str = "", description: str = "", status: str = "", order_index: int = -1, ) -> dict: """Update a Scribe 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() async def delete_milestone(milestone_id: int) -> dict: """Move a milestone to the trash (recoverable). Its tasks go with it as one batch. Restore via restore(batch_id).""" uid = current_user_id() batch = await trash_svc.delete(uid, "milestone", milestone_id) if batch is None: raise ValueError(f"milestone {milestone_id} not found") return {"deleted_batch_id": batch, "message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."} def register(mcp) -> None: for fn in ( list_milestones, create_milestone, update_milestone, delete_milestone, ): mcp.tool(name=fn.__name__)(fn)