"""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)