Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
uv = "0.9.18"
4 changes: 4 additions & 0 deletions plane_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
286 changes: 286 additions & 0 deletions plane_mcp/tools/epics.py
Original file line number Diff line number Diff line change
@@ -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
"""
Comment on lines +89 to +112
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix create_epic docstring to match the actual API.

The Args section includes non-existent parameters (workspace_slug, type_id), and Returns says “WorkItem” while the function returns Epic. This will mislead tool callers.

Suggested fix
         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)
@@
         Returns:
-            Created WorkItem object
+            Created Epic object
As per coding guidelines: “`plane_mcp/tools/*.py`: Tool docstrings must include Args and Returns sections.”
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
"""
Args:
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
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 Epic object
"""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plane_mcp/tools/epics.py` around lines 89 - 112, The docstring for
create_epic is out of sync with the API: remove non-existent parameters
workspace_slug and type_id from the Args list, ensure the Args section exactly
lists the actual function parameters (e.g., project_id, name, assignees, labels,
point, description_html, description_stripped, priority, start_date,
target_date, sort_order, is_draft, external_source, external_id, parent, state,
estimate_point), and update the Returns section to indicate it returns an Epic
(not WorkItem); keep both Args and Returns sections present and concise to match
coding guidelines and the create_epic function signature.

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]
)
Comment on lines +115 to +118
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject invalid priority instead of silently dropping it.

At Line 117, invalid priority values are coerced to None, which hides bad input and creates unexpected updates. create_epic should fail fast, like update_epic.

Suggested fix
-        # Validate priority against allowed literal values
-        validated_priority: PriorityEnum | None = (
-            priority if priority in get_args(PriorityEnum) else None  # type: ignore[assignment]
-        )
+        # 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]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Validate priority against allowed literal values
validated_priority: PriorityEnum | None = (
priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment]
)
# 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]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plane_mcp/tools/epics.py` around lines 115 - 118, The code silently coerces
invalid priority values to None when constructing validated_priority; instead,
in create_epic validate the incoming priority against PriorityEnum (use
get_args(PriorityEnum) or PriorityEnum.__members__) and raise a clear exception
(e.g., ValueError) if the value is not allowed so the call fails fast; update
the logic around validated_priority in the create_epic implementation (and any
helper validating function) to perform this explicit check and raise rather than
assigning None.


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,
)
Loading