diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index 3bac289..9b18199 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -56,6 +56,13 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to avoid duplicates. Coordinate with the operator on whether a new rule belongs in a project, an existing rulebook+topic, or a new rulebook. +When you are working on a specific project, call enter_project(project_id) +ONCE at session start (or whenever the active project changes). It returns the +project, its applicable_rules + project_rules + subscribed_rulebooks, milestone +summary, open tasks, and recent notes — everything you need to know the lay of +the land before mutating. Don't call get_project + get_applicable_rules + a +search separately when enter_project already composes them. + Plans are tasks with kind=plan, and Scribe is the canonical home for them. When you begin non-trivial work, call start_planning(project_id, title) FIRST — before any brainstorming, design, or plan-writing skill runs. start_planning diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py index c17fc63..d31c47c 100644 --- a/src/fabledassistant/mcp/tools/projects.py +++ b/src/fabledassistant/mcp/tools/projects.py @@ -18,6 +18,7 @@ from __future__ import annotations from fabledassistant.mcp._context import current_user_id from fabledassistant.services import milestones as milestones_svc +from fabledassistant.services import notes as notes_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 @@ -34,6 +35,71 @@ async def list_projects() -> dict: 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", []), + "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. @@ -137,6 +203,7 @@ async def delete_project(project_id: int) -> dict: def register(mcp) -> None: for fn in ( list_projects, + enter_project, get_project, create_project, update_project, diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index 696eddb..cb84619 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -6,7 +6,7 @@ import pytest from fabledassistant.mcp._context import _user_id_ctx from fabledassistant.mcp.tools.projects import ( list_projects, get_project, create_project, - update_project, + update_project, enter_project, ) @@ -130,3 +130,75 @@ async def test_update_project_raises_when_not_found(): ): with pytest.raises(ValueError, match="project 999 not found"): await update_project(project_id=999, title="x") + + +@pytest.mark.asyncio +async def test_enter_project_composes_full_context(): + """enter_project pulls project + rules + milestone summary + open tasks + + recent notes in one composed call.""" + p = _fake_project(id=5, title="P") + applicable_payload = { + "rules": [{"id": 1, "title": "r1", "statement": "s", + "topic_title": "t", "rulebook_title": "rb"}], + "project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}], + "truncated": False, + "subscribed_rulebooks": [{"id": 2, "title": "rb"}], + } + milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] + + task1 = MagicMock() + task1.id = 100; task1.title = "T1"; task1.status = "in_progress" + task1.priority = "high"; task1.task_kind = "work"; task1.milestone_id = 10 + note1 = MagicMock() + note1.id = 200; note1.title = "N1" + note1.updated_at = None # avoids datetime mocking + + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=p), + ), patch( + "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable_payload), + ), patch( + "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=milestone_summary), + ), patch( + "fabledassistant.mcp.tools.projects.notes_svc.list_notes", + AsyncMock(side_effect=[([task1], 1), ([note1], 1)]), + ): + out = await enter_project(project_id=5) + + assert out["project"]["id"] == 5 + assert out["milestone_summary"] == milestone_summary + assert out["applicable_rules"][0]["title"] == "r1" + assert out["project_rules"][0]["id"] == 99 + assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}] + assert out["open_tasks"][0]["id"] == 100 + assert out["open_tasks"][0]["status"] == "in_progress" + assert out["recent_notes"][0]["id"] == 200 + + +@pytest.mark.asyncio +async def test_enter_project_raises_when_project_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 enter_project(project_id=999) + + +def test_enter_project_registered_in_register(): + """register(mcp) registers enter_project alongside the existing tools.""" + from fabledassistant.mcp.tools.projects import register + registered: list[str] = [] + + class FakeMCP: + def tool(self, name=None): + def decorator(fn): + registered.append(name) + return fn + return decorator + + register(FakeMCP()) + assert "enter_project" in registered