"""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 scribe.mcp._context import current_user_id from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc from scribe.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/paused/completed/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 enter_project(project_id: int) -> dict: """Session-start handshake: load full context for working on a project. Call this FIRST whenever you're about to do project-scoped work (start_planning, create_task, update_*, anything that takes a project_id). One round-trip returns the project, its applicable rules (both rulebook- subscribed and project-scoped), milestone progress, open tasks, and recently-updated notes — everything you need to know the lay of the land before mutating. No persistent server state: this is a read snapshot. Re-call if the session goes idle long enough that the data feels stale. Args: project_id: The project to enter. Returns a dict with keys: project, milestone_summary, applicable_rules, project_rules, subscribed_rulebooks, applicable_rules_truncated, open_tasks, recent_notes. """ 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") applicable = await rulebooks_svc.get_applicable_rules( project_id=project_id, user_id=uid, ) milestone_summary = await milestones_svc.get_project_milestone_summary( uid, project_id, ) open_tasks, _ = await notes_svc.list_notes( uid, is_task=True, project_id=project_id, status=["todo", "in_progress"], sort="updated_at", limit=10, ) recent_notes, _ = await notes_svc.list_notes( uid, is_task=False, project_id=project_id, sort="updated_at", limit=5, ) return { "project": project.to_dict(), "milestone_summary": milestone_summary, "applicable_rules": applicable["rules"], "project_rules": applicable.get("project_rules", []), "suppressed_rules": applicable.get("suppressed_rules", []), "suppressed_topics": applicable.get("suppressed_topics", []), "subscribed_rulebooks": applicable["subscribed_rulebooks"], "applicable_rules_truncated": applicable["truncated"], "open_tasks": [ { "id": t.id, "title": t.title, "status": t.status, "priority": t.priority, "task_kind": t.task_kind, "milestone_id": t.milestone_id, } for t in open_tasks ], "recent_notes": [ { "id": n.id, "title": n.title, "updated_at": n.updated_at.isoformat() if n.updated_at else None, } for n in recent_notes ], } 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"] data["project_rules"] = applicable.get("project_rules", []) data["suppressed_rules"] = applicable.get("suppressed_rules", []) data["suppressed_topics"] = applicable.get("suppressed_topics", []) 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: one of active (default), paused, completed, 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 — one of active, paused, completed, 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, enter_project, get_project, create_project, update_project, delete_project, ): mcp.tool(name=fn.__name__)(fn)