diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py index b686b92..563ad36 100644 --- a/src/fabledassistant/mcp/server.py +++ b/src/fabledassistant/mcp/server.py @@ -20,6 +20,16 @@ Hierarchy: Project -> Milestone -> Task/Note. unchanged on updates. - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean "not set". + +Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry +an actionable statement plus optional Why and How-to-apply context. When you +start work on a project, get_project(id) returns applicable_rules (the rules +from rulebooks the project subscribes to) and subscribed_rulebooks. Consult +these before making decisions about workflow, conventions, or scope. Full +text (Why / How-to-apply) is available via get_rule(id). You may create new +rules via create_rule when you notice a pattern worth codifying — coordinate +with the operator on whether it belongs in an existing rulebook+topic or a +new one. """ diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py index d99a677..a6c4928 100644 --- a/src/fabledassistant/mcp/tools/projects.py +++ b/src/fabledassistant/mcp/tools/projects.py @@ -19,6 +19,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 projects as projects_svc +from fabledassistant.services import rulebooks as rulebooks_svc async def list_projects() -> dict: @@ -33,10 +34,11 @@ async def list_projects() -> dict: async def get_project(project_id: int) -> dict: - """Fetch a Scribe project by ID, including its milestone summary. + """Fetch a Scribe project by ID. - Returns full project fields plus a milestone_summary list with each - milestone's id, title, status, and task counts. + 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) @@ -46,6 +48,12 @@ async def get_project(project_id: int) -> 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 diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index 936f928..696eddb 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -41,18 +41,57 @@ async def test_list_projects_wraps_in_dict(): async def test_get_project_enriches_with_milestone_summary(): p = _fake_project(id=5, title="found") milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}] + applicable_payload = { + "rules": [], "truncated": False, "subscribed_rulebooks": [], + } with patch( "fabledassistant.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=p), ), patch( "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=milestone_summary), + ), patch( + "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable_payload), ): out = await get_project(project_id=5) assert out["id"] == 5 assert out["milestone_summary"] == milestone_summary +@pytest.mark.asyncio +async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks(): + """The augmented get_project response includes applicable_rules and + subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules. + """ + p = _fake_project(id=3, title="Fabled Assistant") + milestone_summary = [] + applicable_payload = { + "rules": [ + {"id": 1, "title": "dev is home", + "statement": "Work directly on dev", + "topic_title": "git-workflow", + "rulebook_title": "FabledSword family"}, + ], + "truncated": False, + "subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}], + } + with patch( + "fabledassistant.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=p), + ), patch( + "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=milestone_summary), + ), patch( + "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value=applicable_payload), + ): + out = await get_project(project_id=3) + assert out["applicable_rules"][0]["title"] == "dev is home" + assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}] + assert out["applicable_rules_truncated"] is False + + @pytest.mark.asyncio async def test_get_project_raises_when_not_found(): with patch(