"""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 from fabledassistant.services import rulebooks as rulebooks_svc from fabledassistant.services import trash as trash_svc async def list_projects() -> dict: """List all Scribe 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 get_project(project_id: int) -> dict: """Fetch a Scribe project by ID. Returns full project fields, a milestone_summary list, and the rulebook-applicable_rules / subscribed_rulebooks pair the assistant should consult when working on this project. """ 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, ) applicable = await rulebooks_svc.get_applicable_rules( project_id=project_id, user_id=uid, ) data["applicable_rules"] = applicable["rules"] data["applicable_rules_truncated"] = applicable["truncated"] data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"] return data async def create_project( title: str, description: str = "", goal: str = "", status: str = "active", color: str = "", ) -> dict: """Create a new project in Scribe. 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 update_project( project_id: int, title: str = "", description: str = "", goal: str = "", status: str = "", color: str = "", ) -> dict: """Update an existing Scribe 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() async def delete_project(project_id: int) -> dict: """Move a project to the trash (recoverable). Its milestones, tasks, and notes go with it as one batch. Restore via restore(batch_id).""" uid = current_user_id() batch = await trash_svc.delete(uid, "project", project_id) if batch is None: raise ValueError(f"project {project_id} not found") return {"deleted_batch_id": batch, "message": f"Project {project_id} + its contents moved to trash. Restore with restore('{batch}')."} def register(mcp) -> None: for fn in ( list_projects, get_project, create_project, update_project, delete_project, ): mcp.tool(name=fn.__name__)(fn)