diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..f69e5e5 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +uv = "0.9.18" diff --git a/plane_mcp/tools/__init__.py b/plane_mcp/tools/__init__.py index ea2296b..d0c09c4 100644 --- a/plane_mcp/tools/__init__.py +++ b/plane_mcp/tools/__init__.py @@ -3,9 +3,11 @@ from fastmcp import FastMCP from plane_mcp.tools.cycles import register_cycle_tools +from plane_mcp.tools.epics import register_epic_tools from plane_mcp.tools.initiatives import register_initiative_tools from plane_mcp.tools.intake import register_intake_tools from plane_mcp.tools.labels import register_label_tools +from plane_mcp.tools.milestones import register_milestone_tools from plane_mcp.tools.modules import register_module_tools from plane_mcp.tools.pages import register_page_tools from plane_mcp.tools.projects import register_project_tools @@ -42,3 +44,5 @@ def register_tools(mcp: FastMCP) -> None: register_work_item_type_tools(mcp) register_state_tools(mcp) register_workspace_tools(mcp) + register_epic_tools(mcp) + register_milestone_tools(mcp) \ No newline at end of file diff --git a/plane_mcp/tools/epics.py b/plane_mcp/tools/epics.py new file mode 100644 index 0000000..4f0f42b --- /dev/null +++ b/plane_mcp/tools/epics.py @@ -0,0 +1,286 @@ +"""Epic-related tools for Plane MCP Server.""" +from typing import get_args + +from fastmcp import FastMCP +from plane import PlaneClient +from plane.models.enums import PriorityEnum +from plane.models.epics import Epic, PaginatedEpicResponse +from plane.models.query_params import PaginatedQueryParams, RetrieveQueryParams +from plane.models.work_item_types import WorkItemType +from plane.models.work_items import ( + CreateWorkItem, + UpdateWorkItem, +) + +from plane_mcp.client import get_plane_client_context + + +def register_epic_tools(mcp: FastMCP) -> None: + """Register all epic-related tools with the MCP server.""" + + def _get_epic_work_item_type(client: PlaneClient, workspace_slug: str, project_id: str) -> WorkItemType | None: + """Helper function to get the work item type ID for epics.""" + response = client.work_item_types.list( + workspace_slug=workspace_slug, + project_id=project_id,) + + for work_item_type in response: + if work_item_type.is_epic: + return work_item_type + + return None + + @mcp.tool() + def list_epics( + project_id: str, + cursor: str | None = None, + per_page: int | None = None, + ) -> list[Epic]: + """ + List all epics in a project. + + Args: + project_id: UUID of the project + cursor: Pagination cursor for getting next set of results + per_page: Number of results per page (1-100) + + Returns: + List of Epic objects + """ + client, workspace_slug = get_plane_client_context() + + params = PaginatedQueryParams( + cursor=cursor, + per_page=per_page, + ) + + response: PaginatedEpicResponse = client.epics.list( + workspace_slug=workspace_slug, + project_id=project_id, + params=params, + ) + + return response.results + + + @mcp.tool() + def create_epic( + project_id: str, + name: str, + assignees: list[str] | None = None, + labels: list[str] | None = None, + point: int | None = None, + description_html: str | None = None, + description_stripped: str | None = None, + priority: str | None = None, + start_date: str | None = None, + target_date: str | None = None, + sort_order: float | None = None, + is_draft: bool | None = None, + external_source: str | None = None, + external_id: str | None = None, + parent: str | None = None, + state: str | None = None, + estimate_point: str | None = None, + ) -> Epic: + """ + Create a new epic. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + name: Epic name (required) + assignees: List of user IDs to assign to the epic + labels: List of label IDs to attach to the epic + type_id: UUID of the epic type + point: Story point value + description_html: HTML description of the epic + description_stripped: Plain text description (stripped of HTML) + priority: Priority level (urgent, high, medium, low, none) + start_date: Start date (ISO 8601 format) + target_date: Target/end date (ISO 8601 format) + sort_order: Sort order value + is_draft: Whether the epic is a draft + external_source: External system source name + external_id: External system identifier + parent: UUID of the parent epic + state: UUID of the state + estimate_point: Estimate point value + + Returns: + Created WorkItem object + """ + client, workspace_slug = get_plane_client_context() + + # Validate priority against allowed literal values + validated_priority: PriorityEnum | None = ( + priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment] + ) + + epic_type = _get_epic_work_item_type(client, workspace_slug, project_id) + + if epic_type is None: + raise ValueError("No work item type with is_epic=True found in the project") + + data = CreateWorkItem( + name=name, + assignees=assignees, + labels=labels, + type_id=epic_type.id, + point=point, + description_html=description_html, + description_stripped=description_stripped, + priority=validated_priority, + start_date=start_date, + target_date=target_date, + sort_order=sort_order, + is_draft=is_draft, + external_source=external_source, + external_id=external_id, + parent=parent, + state=state, + estimate_point=estimate_point, + ) + + work_item = client.work_items.create( + workspace_slug=workspace_slug, project_id=project_id, data=data + ) + + return client.epics.retrieve( + workspace_slug=workspace_slug, + project_id=project_id, + epic_id=work_item.id, + ) + + @mcp.tool() + def update_epic( + project_id: str, + epic_id: str, + name: str | None = None, + assignees: list[str] | None = None, + labels: list[str] | None = None, + point: int | None = None, + description_html: str | None = None, + description_stripped: str | None = None, + priority: str | None = None, + start_date: str | None = None, + target_date: str | None = None, + sort_order: float | None = None, + is_draft: bool | None = None, + external_source: str | None = None, + external_id: str | None = None, + state: str | None = None, + estimate_point: str | None = None, + ) -> Epic: + """ + Update an epic by ID. + + Args: + project_id: UUID of the project + epic_id: UUID of the epic + name: Epic name + assignees: List of user IDs to assign to the epic + labels: List of label IDs to attach to the epic + point: Story point value + description_html: HTML description of the epic + description_stripped: Plain text description (stripped of HTML) + priority: Priority level (urgent, high, medium, low, none) + start_date: Start date (ISO 8601 format) + target_date: Target/end date (ISO 8601 format) + sort_order: Sort order value + is_draft: Whether the epic is a draft + external_source: External system source name + external_id: External system identifier + state: UUID of the state + estimate_point: Estimate point value + + Returns: + Updated Epic object + """ + client, workspace_slug = get_plane_client_context() + + # Validate priority against allowed literal values + valid_priorities = get_args(PriorityEnum) + if priority is not None and priority not in valid_priorities: + raise ValueError(f"Invalid priority '{priority}'. Must be one of: {valid_priorities}") + validated_priority: PriorityEnum | None = priority # type: ignore[assignment] + + data = UpdateWorkItem( + name=name, + assignees=assignees, + labels=labels, + point=point, + description_html=description_html, + description_stripped=description_stripped, + priority=validated_priority, + start_date=start_date, + target_date=target_date, + sort_order=sort_order, + is_draft=is_draft, + external_source=external_source, + external_id=external_id, + state=state, + estimate_point=estimate_point + ) + + work_item = client.work_items.update( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=epic_id, + data=data, + ) + + return client.epics.retrieve( + workspace_slug=workspace_slug, + project_id=project_id, + epic_id=work_item.id, + ) + + @mcp.tool() + def retrieve_epic( + project_id: str, + epic_id: str, + ) -> Epic: + """ + Retrieve an epic by ID. + + Args: + project_id: UUID of the project + epic_id: UUID of the epic + + Returns: + Epic object + """ + client, workspace_slug = get_plane_client_context() + + params = RetrieveQueryParams() + + return client.epics.retrieve( + workspace_slug=workspace_slug, + project_id=project_id, + epic_id=epic_id, + params=params, + ) + + @mcp.tool() + def delete_epic( + project_id: str, + epic_id: str, + ) -> None: + """ + Delete an epic by ID. + + Args: + project_id: UUID of the project + epic_id: UUID of the epic + + Returns: + None + """ + client, workspace_slug = get_plane_client_context() + + return client.work_items.delete( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=epic_id, + ) diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py new file mode 100644 index 0000000..8012f71 --- /dev/null +++ b/plane_mcp/tools/milestones.py @@ -0,0 +1,214 @@ +"""Milestone-related tools for Plane MCP Server.""" + +from typing import Any + +from fastmcp import FastMCP +from plane.models.milestones import ( + CreateMilestone, + Milestone, + MilestoneWorkItem, + PaginatedMilestoneResponse, + PaginatedMilestoneWorkItemResponse, + UpdateMilestone, +) + +from plane_mcp.client import get_plane_client_context + + +def register_milestone_tools(mcp: FastMCP) -> None: + """Register all milestone-related tools with the MCP server.""" + + @mcp.tool() + def list_milestones( + project_id: str, + params: dict[str, Any] | None = None, + ) -> list[Milestone]: + """ + List all milestones in a project. + + Args: + project_id: UUID of the project + params: Optional query parameters as a dictionary + + Returns: + List of Milestone objects + """ + client, workspace_slug = get_plane_client_context() + response: PaginatedMilestoneResponse = client.milestones.list( + workspace_slug=workspace_slug, project_id=project_id, params=params + ) + return response.results + + @mcp.tool() + def create_milestone( + project_id: str, + title: str, + target_date: str | None = None, + external_source: str | None = None, + external_id: str | None = None, + ) -> Milestone: + """ + Create a new milestone. + + Args: + project_id: UUID of the project + title: Milestone title + target_date: Target date for the milestone (ISO 8601 format) + external_source: External system source name + external_id: External system identifier + + Returns: + Created Milestone object + """ + client, workspace_slug = get_plane_client_context() + + data = CreateMilestone( + title=title, + target_date=target_date, + external_source=external_source, + external_id=external_id, + ) + + return client.milestones.create( + workspace_slug=workspace_slug, project_id=project_id, data=data + ) + + @mcp.tool() + def retrieve_milestone(project_id: str, milestone_id: str) -> Milestone: + """ + Retrieve a milestone by ID. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + + Returns: + Milestone object + """ + client, workspace_slug = get_plane_client_context() + return client.milestones.retrieve( + workspace_slug=workspace_slug, project_id=project_id, milestone_id=milestone_id + ) + + @mcp.tool() + def update_milestone( + project_id: str, + milestone_id: str, + title: str | None = None, + target_date: str | None = None, + external_source: str | None = None, + external_id: str | None = None, + ) -> Milestone: + """ + Update a milestone by ID. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + title: Milestone title + target_date: Target date for the milestone (ISO 8601 format) + external_source: External system source name + external_id: External system identifier + + Returns: + Updated Milestone object + """ + client, workspace_slug = get_plane_client_context() + + data = UpdateMilestone( + title=title, + target_date=target_date, + external_source=external_source, + external_id=external_id, + ) + + return client.milestones.update( + workspace_slug=workspace_slug, + project_id=project_id, + milestone_id=milestone_id, + data=data, + ) + + @mcp.tool() + def delete_milestone(project_id: str, milestone_id: str) -> None: + """ + Delete a milestone by ID. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + """ + client, workspace_slug = get_plane_client_context() + client.milestones.delete( + workspace_slug=workspace_slug, project_id=project_id, milestone_id=milestone_id + ) + + @mcp.tool() + def add_work_items_to_milestone( + project_id: str, + milestone_id: str, + issue_ids: list[str], + ) -> None: + """ + Add work items to a milestone. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + issue_ids: List of work item IDs to add to the milestone + """ + client, workspace_slug = get_plane_client_context() + client.milestones.add_work_items( + workspace_slug=workspace_slug, + project_id=project_id, + milestone_id=milestone_id, + issue_ids=issue_ids, + ) + + @mcp.tool() + def remove_work_items_from_milestone( + project_id: str, + milestone_id: str, + issue_ids: list[str], + ) -> None: + """ + Remove work items from a milestone. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + issue_ids: List of work item IDs to remove from the milestone + """ + client, workspace_slug = get_plane_client_context() + client.milestones.remove_work_items( + workspace_slug=workspace_slug, + project_id=project_id, + milestone_id=milestone_id, + issue_ids=issue_ids, + ) + + @mcp.tool() + def list_milestone_work_items( + project_id: str, + milestone_id: str, + params: dict[str, Any] | None = None, + ) -> list[MilestoneWorkItem]: + """ + List work items in a milestone. + + Args: + project_id: UUID of the project + milestone_id: UUID of the milestone + params: Optional query parameters as a dictionary + + Returns: + List of MilestoneWorkItem objects in the milestone + """ + client, workspace_slug = get_plane_client_context() + response: PaginatedMilestoneWorkItemResponse = client.milestones.list_work_items( + workspace_slug=workspace_slug, + project_id=project_id, + milestone_id=milestone_id, + params=params, + ) + return response.results diff --git a/pyproject.toml b/pyproject.toml index 083ed93..2f1e87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ keywords = ["mcp", "plane", "fastmcp", "ai", "automation"] dependencies = [ "fastmcp==2.14.1", - "plane-sdk==0.2.2", + "plane-sdk==0.2.6", "py-key-value-aio[redis]==0.3.0", "mcp==1.24.0", ] diff --git a/tests/test_integration.py b/tests/test_integration.py index 2a49064..9ff5635 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -54,12 +54,20 @@ async def run_integration_test(): Full integration test: 1. Create a project 2. Create work item 1 - 3. Create work item 2 - 4. Update work item 2 with work item 1 as parent - 5. Delete work items - 6. Delete project - """ - config = get_config() + 3. Create work item 2 + 4. Update work item 2 with work item 1 as parent + 5. Create epic with work item 1 as the underlying work item + 6. Update work item 2 to be under the epic + 7. List all epics + 8. Create a milestone and associate it with the project and work items + 9. Update the milestone to change its name and description + 10. List all milestones in the project + 11. Delete the milestone + 12. Delete the epic + 13. Delete work items + 14. Delete project + """ + config = get_config() unique_id = uuid.uuid4().hex[:6] transport = StreamableHttpTransport( @@ -72,7 +80,7 @@ async def run_integration_test(): async with Client(transport=transport) as client: # 1. Create project - print(f"Creating project...") + print("Creating project...") project_result = await client.call_tool( "create_project", { @@ -86,7 +94,7 @@ async def run_integration_test(): print(f"Created project: {project_id}") # 2. Create work item 1 - print(f"Creating work item 1...") + print("Creating work item 1...") work_item_1_result = await client.call_tool( "create_work_item", { @@ -99,7 +107,7 @@ async def run_integration_test(): print(f"Created work item 1: {work_item_1_id}") # 3. Create work item 2 - print(f"Creating work item 2...") + print("Creating work item 2...") work_item_2_result = await client.call_tool( "create_work_item", { @@ -112,7 +120,7 @@ async def run_integration_test(): print(f"Created work item 2: {work_item_2_id}") # 4. Update work item 2 with work item 1 as parent - print(f"Setting parent relationship...") + print("Setting parent relationship...") await client.call_tool( "update_work_item", { @@ -121,26 +129,117 @@ async def run_integration_test(): "parent": work_item_1_id, }, ) - print(f"Set work item 1 as parent of work item 2") + print("Set work item 1 as parent of work item 2") + + # 5. Create epic with work item 1 as the underlying work item + print("Creating epic...") + + epic_result = await client.call_tool( + "create_epic", + { + "project_id": project_id, + "name": f"Epic {unique_id}", + }, + ) + + epic = extract_result(epic_result) + + epic_id = epic["id"] + + print(f"Created epic: {epic_id}") - # 5. Delete work items - print(f"Deleting work items...") + # 6. Update work item 2 to be under the epic + print("Setting parent relationship to epic...") + await client.call_tool( + "update_work_item", + { + "project_id": project_id, + "work_item_id": work_item_2_id, + "parent": epic_id, + }, + ) + print("Set epic as parent of work item 2") + + # 7. List all epics + print("Listing epics in project...") + epics_result = await client.call_tool( + "list_epics", + { + "project_id": project_id, + }, + ) + epics = extract_result(epics_result) + print(f"Epics in project: {[e['id'] for e in epics]}") + + # 8. Create a milestone and associate it with the project and work items + print("Creating milestone...") + milestone_result = await client.call_tool( + "create_milestone", + { + "project_id": project_id, + "name": f"Milestone {unique_id}", + "description": "Integration test milestone", + "associated_work_item_ids": [epic_id, work_item_1_id, work_item_2_id], + }, + ) + milestone = extract_result(milestone_result) + milestone_id = milestone["id"] + + print("List work items associated with milestone...") + + milestone_details_result = await client.call_tool( + "list_milestone_work_items", + { + "project_id": project_id, + "milestone_id": milestone_id, + }, + ) + + milestone_work_items = extract_result(milestone_details_result) + print(f"Work items associated with milestone: {[wi['id'] for wi in milestone_work_items]}") + + print(f"Created milestone: {milestone_id}") + + # 9. Update the milestone to change its name and description + print("Updating milestone...") + await client.call_tool( + "update_milestone", + { + "project_id": project_id, + "milestone_id": milestone_id, + "name": f"Updated Milestone {unique_id}", + "description": "Updated description for integration test milestone" + }, + ) + + print("Updated milestone") + + # 8. Delete work items + print("Deleting work items...") await client.call_tool( "delete_work_item", {"project_id": project_id, "work_item_id": work_item_2_id}, ) - print(f"Deleted work item 2") + print("Deleted work item 2") await client.call_tool( "delete_work_item", {"project_id": project_id, "work_item_id": work_item_1_id}, ) - print(f"Deleted work item 1") + print("Deleted work item 1") + + # 9. Delete epic + print("Deleting epic...") + await client.call_tool( + "delete_epic", + {"project_id": project_id, "epic_id": epic_id}, + ) + print("Deleted epic") - # 6. Delete project - print(f"Deleting project...") + # 10. Delete project + print("Deleting project...") await client.call_tool("delete_project", {"project_id": project_id}) - print(f"Deleted project") + print("Deleted project") print("Integration test passed!") @@ -260,6 +359,12 @@ def test_full_integration(): "retrieve_work_item_property", "update_work_item_property", "delete_work_item_property", + # Epic tools + "list_epics", + "retrieve_epic", + "create_epic", + "update_epic", + "delete_epic", ] diff --git a/uv.lock b/uv.lock index aa78377..8c18145 100644 --- a/uv.lock +++ b/uv.lock @@ -924,7 +924,7 @@ wheels = [ [[package]] name = "plane-mcp-server" -version = "0.2.1" +version = "0.2.4" source = { editable = "." } dependencies = [ { name = "fastmcp" }, @@ -945,7 +945,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastmcp", specifier = "==2.14.1" }, { name = "mcp", specifier = "==1.24.0" }, - { name = "plane-sdk", specifier = "==0.2.2" }, + { name = "plane-sdk", specifier = "==0.2.6" }, { name = "py-key-value-aio", extras = ["redis"], specifier = "==0.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, @@ -954,15 +954,15 @@ provides-extras = ["dev"] [[package]] name = "plane-sdk" -version = "0.2.2" +version = "0.2.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/ee/ac7c0e4eea10b4324bb2b91fd7ef0bd690dc0d7c75ad63d0133975917c6e/plane_sdk-0.2.2.tar.gz", hash = "sha256:257e0068d91d56479909fc3c543c4d56668e76c69fd0561f352f420305662348", size = 45060, upload-time = "2025-11-29T10:00:30.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3ab01274ec22912725cddbb4cdeacf95c7ff673328702ad1fbb371f52062/plane_sdk-0.2.6.tar.gz", hash = "sha256:17be008d94e398c8cd1ba10686a767906b313aae5382e2f3cf0e3066074ee0af", size = 47581, upload-time = "2026-02-10T16:53:52.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a1/7deeb618fa1b7f90bfa89ec5ec93e5d50c9958cfbd5336c5bd7823f8b15a/plane_sdk-0.2.2-py3-none-any.whl", hash = "sha256:94765d29d870e40cc2bc8a067f2b917cf3ab4b6dbc249914900f0366b8996e4f", size = 65759, upload-time = "2025-11-29T10:00:28.845Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/c185e629c1a97c46b9869ea80abe9b47080f625556426c9621e0a32bb4b0/plane_sdk-0.2.6-py3-none-any.whl", hash = "sha256:41bc019db35ba5a5834d7b072ef102721f3e274162253e5d0da1831a52c3a1a4", size = 70967, upload-time = "2026-02-10T16:53:51.098Z" }, ] [[package]]