From be95f44e9f23741a31a4684a39e8570cf2f87711 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Fri, 12 Jun 2026 21:39:39 +0530 Subject: [PATCH 1/2] refactor: remove epic tools and update README with new work item type instructions --- README.md | 12 +- plane_mcp/instructions.py | 45 +++++ plane_mcp/server.py | 4 + plane_mcp/tools/__init__.py | 2 - plane_mcp/tools/epics.py | 285 ----------------------------- plane_mcp/tools/initiatives.py | 22 ++- plane_mcp/tools/projects.py | 3 - plane_mcp/tools/work_item_types.py | 118 +++++++++++- tests/test_integration.py | 65 +++++-- 9 files changed, 227 insertions(+), 329 deletions(-) create mode 100644 plane_mcp/instructions.py delete mode 100644 plane_mcp/tools/epics.py diff --git a/README.md b/README.md index f407630..8d34a5f 100644 --- a/README.md +++ b/README.md @@ -222,16 +222,6 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `update_work_item_property` | Update a work item property with partial data | | `delete_work_item_property` | Delete a work item property by ID | -### Epics - -| Tool Name | Description | -|-----------|-------------| -| `list_epics` | List all epics in a project | -| `create_epic` | Create a new epic | -| `retrieve_epic` | Retrieve an epic by ID | -| `update_epic` | Update an epic by ID | -| `delete_epic` | Delete an epic by ID | - ### Milestones | Tool Name | Description | @@ -294,6 +284,8 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `retrieve_work_item_type` | Retrieve a work item type by ID | | `update_work_item_type` | Update a work item type by ID | | `delete_work_item_type` | Delete a work item type by ID | +| `import_work_item_types_to_project` | Bulk-link workspace-level work item types to a project | +| `resolve_work_item_type` | Find or create a named type for a project, auto-handling workspace vs project scope and import | ### Work Item Relations diff --git a/plane_mcp/instructions.py b/plane_mcp/instructions.py new file mode 100644 index 0000000..9361cb8 --- /dev/null +++ b/plane_mcp/instructions.py @@ -0,0 +1,45 @@ +"""Server-level instructions sent once to MCP clients (FastMCP `instructions` param).""" + +WORK_ITEM_TYPE_SCOPING_INSTRUCTIONS = """ +## Work item type scoping + +To get a usable work item type for a project (e.g. "Epic", "Initiative"), call resolve_work_item_type(project_id, name). It returns the type (its id is the type_id for create_work_item) and handles everything in one step: +- If the workspace owns work item types, it finds or creates the type at the workspace level and imports it into the project (project-level creation is not allowed in this mode). +- Otherwise it finds or creates the type at the project level, enabling the project's work item types feature first if needed. + +Prefer this single tool over manually combining get_workspace_features, list_work_item_types, create_work_item_type, and import_work_item_types_to_project — it does all of that deterministically and never creates a duplicate. +""" + +EPIC_INSTRUCTIONS = """ +## Epics + +This server has no dedicated epic tools (no create_epic, list_epics, retrieve_epic, update_epic, delete_epic, list_epic_issues, add_epic_issues). An "epic" is just a work item whose work item type is named "Epic". + +1. type = resolve_work_item_type(project_id, "Epic") — see "Work item type scoping". +2. Create: create_work_item(project_id=project_id, type_id=type.id, name=). +3. List epics: list_work_items(project_id=project_id, pql='type = ""') (or pql='isEpic()'). +4. Read, update, or delete: retrieve_work_item / update_work_item / delete_work_item, using the epic's work item id. +5. Nest a work item under an epic: create_work_item or update_work_item with parent=. +6. List an epic's children: list_work_items(project_id=project_id, pql='childOf("")'), where is the epic's human-readable identifier (e.g. "PROJ-12") from retrieve_work_item. +""" + +INITIATIVE_INSTRUCTIONS = """ +## Initiatives + +Call get_workspace_features() first. Pick exactly one path — never mix them. + +If initiatives is true — native workspace-level objects (no project_id needed): +- Create: create_initiative(name=...). +- List: list_initiatives(). +- Read/update/delete: retrieve_initiative / update_initiative / delete_initiative by initiative id. + +If initiatives is false — fall back to an "Initiative" work item type inside a project: +1. If the user has not named a project, ask which project to use before proceeding. +2. type = resolve_work_item_type(project_id, "Initiative") — handles everything: checks if the type is already in the project, finds or creates it at the workspace level if workspace owns types (the common case — "Initiative" is normally a workspace-level type imported into projects), or creates it at the project level if the project owns its own types. Never creates a duplicate. +3. Create: create_work_item(project_id=project_id, type_id=type.id, name=). +4. List: list_work_items(project_id=project_id, pql='type = ""'). +5. Read/update/delete: retrieve_work_item / update_work_item / delete_work_item by work item id. +Use this fallback only when initiatives is false. +""" + +SERVER_INSTRUCTIONS = WORK_ITEM_TYPE_SCOPING_INSTRUCTIONS + EPIC_INSTRUCTIONS + INITIATIVE_INSTRUCTIONS diff --git a/plane_mcp/server.py b/plane_mcp/server.py index 0b1ca8c..8d4be79 100644 --- a/plane_mcp/server.py +++ b/plane_mcp/server.py @@ -9,6 +9,7 @@ from mcp.types import Icon from plane_mcp.auth import PlaneHeaderAuthProvider, PlaneOAuthProvider +from plane_mcp.instructions import SERVER_INSTRUCTIONS from plane_mcp.storage import build_token_store from plane_mcp.tools import register_tools @@ -17,6 +18,7 @@ def get_oauth_mcp(base_path: str = "/") -> FastMCP: """Build the FastMCP instance for the OAuth HTTP / SSE transports.""" oauth_mcp = FastMCP( "Plane MCP Server", + instructions=SERVER_INSTRUCTIONS, icons=[Icon(src="https://plane.so/favicon.ico", alt="Plane MCP Server")], website_url="https://plane.so", auth=PlaneOAuthProvider( @@ -53,6 +55,7 @@ def get_oauth_mcp(base_path: str = "/") -> FastMCP: def get_header_mcp(): header_mcp = FastMCP( "Plane MCP Server (header-http)", + instructions=SERVER_INSTRUCTIONS, auth=PlaneHeaderAuthProvider( required_scopes=["read", "write"], ), @@ -65,6 +68,7 @@ def get_header_mcp(): def get_stdio_mcp(): stdio_mcp = FastMCP( "Plane MCP Server (stdio)", + instructions=SERVER_INSTRUCTIONS, ) stdio_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True)) register_tools(stdio_mcp) diff --git a/plane_mcp/tools/__init__.py b/plane_mcp/tools/__init__.py index ce39fc0..ac681a6 100644 --- a/plane_mcp/tools/__init__.py +++ b/plane_mcp/tools/__init__.py @@ -3,7 +3,6 @@ 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 @@ -45,6 +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) register_pql_tools(mcp) diff --git a/plane_mcp/tools/epics.py b/plane_mcp/tools/epics.py deleted file mode 100644 index 9385a17..0000000 --- a/plane_mcp/tools/epics.py +++ /dev/null @@ -1,285 +0,0 @@ -"""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/initiatives.py b/plane_mcp/tools/initiatives.py index 1948340..07c4787 100644 --- a/plane_mcp/tools/initiatives.py +++ b/plane_mcp/tools/initiatives.py @@ -3,6 +3,7 @@ from typing import Any from fastmcp import FastMCP +from fastmcp.exceptions import ToolError from plane.models.enums import InitiativeState from plane.models.initiatives import ( CreateInitiative, @@ -25,7 +26,6 @@ def list_initiatives( List all initiatives in a workspace. Args: - workspace_slug: The workspace slug identifier params: Optional query parameters as a dictionary (e.g., per_page, cursor) Returns: @@ -49,7 +49,6 @@ def create_initiative( Create a new initiative in the workspace. Args: - workspace_slug: The workspace slug identifier name: Initiative name description_html: HTML description of the initiative start_date: Initiative start date (ISO 8601 format) @@ -60,9 +59,25 @@ def create_initiative( Returns: Created Initiative object + + Raises: + ToolError: if the workspace's initiatives feature is disabled. Native + initiatives require the feature to be enabled in workspace settings. + When disabled, create an "Initiative" work item instead + (see the "Initiatives" server instructions). """ client, workspace_slug = get_plane_client_context() + features = client.workspaces.get_features(workspace_slug=workspace_slug) + if not features.model_dump().get("initiatives"): + raise ToolError( + f"The initiatives feature is disabled for this workspace. " + f"Create {repr(name)} as an \"Initiative\" work item instead:\n" + f"1. Work items belong to a project — if not named, ask the user which project to use.\n" + f"2. type = resolve_work_item_type(project_id, \"Initiative\") — finds or creates the type at workspace or project level automatically.\n" + f"3. create_work_item(project_id=project_id, type_id=type.id, name={repr(name)})." + ) + data = CreateInitiative( name=name, description_html=description_html, @@ -81,7 +96,6 @@ def retrieve_initiative(initiative_id: str) -> Initiative: Retrieve an initiative by ID. Args: - workspace_slug: The workspace slug identifier initiative_id: UUID of the initiative Returns: @@ -105,7 +119,6 @@ def update_initiative( Update an initiative by ID. Args: - workspace_slug: The workspace slug identifier initiative_id: UUID of the initiative name: Initiative name description_html: HTML description of the initiative @@ -138,7 +151,6 @@ def delete_initiative(initiative_id: str) -> None: Delete an initiative by ID. Args: - workspace_slug: The workspace slug identifier initiative_id: UUID of the initiative """ client, workspace_slug = get_plane_client_context() diff --git a/plane_mcp/tools/projects.py b/plane_mcp/tools/projects.py index c9e247a..64b8779 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -348,7 +348,6 @@ def get_project_features(project_id: str) -> ProjectFeature: @mcp.tool() def update_project_features( project_id: str, - epics: bool | None = None, modules: bool | None = None, cycles: bool | None = None, views: bool | None = None, @@ -362,7 +361,6 @@ def update_project_features( Args: workspace_slug: The workspace slug identifier project_id: UUID of the project - epics: Enable/disable epics feature modules: Enable/disable modules feature cycles: Enable/disable cycles feature views: Enable/disable views feature @@ -376,7 +374,6 @@ def update_project_features( client, workspace_slug = get_plane_client_context() data = ProjectFeature( - epics=epics, modules=modules, cycles=cycles, views=views, diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index c6549ae..fec4a88 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -3,6 +3,7 @@ from typing import Any from fastmcp import FastMCP +from plane.models.projects import ProjectFeature from plane.models.work_item_types import ( CreateWorkItemType, UpdateWorkItemType, @@ -39,8 +40,8 @@ def create_work_item_type( project_id: str | None = None, description: str | None = None, project_ids: list[str] | None = None, - is_epic: bool | None = None, is_active: bool | None = None, + level: int | None = None, external_source: str | None = None, external_id: str | None = None, ) -> WorkItemType: @@ -52,8 +53,8 @@ def create_work_item_type( project_id: UUID of the project. Omit for workspace-level type. description: Work item type description project_ids: List of project IDs this type applies to - is_epic: Whether this is an epic type is_active: Whether the type is active + level: Hierarchy level (workspace-level types only; ignored for project-level types) external_source: External system source name external_id: External system identifier @@ -66,8 +67,8 @@ def create_work_item_type( name=name, description=description, project_ids=project_ids, - is_epic=is_epic, is_active=is_active, + level=level, external_source=external_source, external_id=external_id, ) @@ -78,6 +79,109 @@ def create_work_item_type( ) return client.workspace_work_item_types.create(workspace_slug=workspace_slug, data=data) + @mcp.tool() + def import_work_item_types_to_project( + project_id: str, + work_item_type_ids: list[str], + ) -> None: + """ + Bulk-link workspace-level work item types to a project. + + Imports one or more workspace-scoped work item types into a project so + that they become available for use within that project. + + Args: + project_id: UUID of the project + work_item_type_ids: List of workspace-level work item type UUIDs to import + """ + client, workspace_slug = get_plane_client_context() + client.work_item_types.import_to_project( + workspace_slug=workspace_slug, project_id=project_id, work_item_type_ids=work_item_type_ids + ) + + @mcp.tool() + def resolve_work_item_type( + project_id: str, + name: str, + level: int | None = None, + ) -> WorkItemType: + """ + Find a work item type by name for a project, create it if missing, and + guarantee it is usable inside that project. Use this to resolve the + type_id for a typed work item such as an "Epic" or "Initiative" before + calling create_work_item(type_id=...). + + Handles workspace-level and project-level work item types automatically, + so the caller never has to decide which mode the workspace is in: + - If the workspace owns work item types, the type is found (or created) + at the workspace level and imported into the project. Project-level + creation is blocked in this mode, so importing is the only valid path. + - Otherwise the type is found (or created) at the project level, enabling + the project's work item types feature first if it is off. + + Matching is exact (case-sensitive, whitespace-stripped); an existing type is never duplicated. + + Args: + project_id: UUID of the project the type must be usable in + name: Work item type name, e.g. "Epic" or "Initiative" + level: Hierarchy level to assign ONLY when creating a new workspace-level type. + Ignored if the type already exists (use update_work_item_type to change + an existing type's level). Has no effect on project-level types (Mode B). + + Returns: + The WorkItemType. Its `id` is the `type_id` for create_work_item. + """ + client, workspace_slug = get_plane_client_context() + target = name.strip() + + workspace_features = client.workspaces.get_features(workspace_slug=workspace_slug) + workspace_owns_types = bool(workspace_features.model_dump().get("work_item_types")) + + if workspace_owns_types: + in_project = next( + (t for t in client.work_item_types.list(workspace_slug=workspace_slug, project_id=project_id) if (t.name or "").strip() == target), + None, + ) + if in_project is not None: + return in_project + at_workspace = next( + (t for t in client.workspace_work_item_types.list(workspace_slug=workspace_slug) if (t.name or "").strip() == target), + None, + ) + if at_workspace is None: + at_workspace = client.workspace_work_item_types.create( + workspace_slug=workspace_slug, data=CreateWorkItemType(name=name, level=level) + ) + client.work_item_types.import_to_project( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_type_ids=[at_workspace.id], + ) + return at_workspace + + # Mode B — types are per-project; enable the feature if needed, then find or create. + project_features = client.projects.get_features( + workspace_slug=workspace_slug, project_id=project_id + ) + if not project_features.model_dump().get("work_item_types"): + client.projects.update_features( + workspace_slug=workspace_slug, + project_id=project_id, + data=ProjectFeature(work_item_types=True), + ) + + existing = next( + (t for t in client.work_item_types.list(workspace_slug=workspace_slug, project_id=project_id) if (t.name or "").strip() == target), + None, + ) + if existing is None: + existing = client.work_item_types.create( + workspace_slug=workspace_slug, + project_id=project_id, + data=CreateWorkItemType(name=name), + ) + return existing + @mcp.tool() def retrieve_work_item_type( work_item_type_id: str, @@ -112,8 +216,8 @@ def update_work_item_type( name: str | None = None, description: str | None = None, project_ids: list[str] | None = None, - is_epic: bool | None = None, is_active: bool | None = None, + level: int | None = None, external_source: str | None = None, external_id: str | None = None, ) -> WorkItemType: @@ -126,8 +230,10 @@ def update_work_item_type( name: Work item type name description: Work item type description project_ids: List of project IDs this type applies to - is_epic: Whether this is an epic type is_active: Whether the type is active + level: Hierarchy level (workspace-level types only; ignored for project-level types). + Note: for Epic types (is_epic=true), only is_active can be changed — level + and all other fields are immutable and the server will reject the request. external_source: External system source name external_id: External system identifier @@ -140,8 +246,8 @@ def update_work_item_type( name=name, description=description, project_ids=project_ids, - is_epic=is_epic, is_active=is_active, + level=level, external_source=external_source, external_id=external_id, ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 9ff5635..636d02f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -56,16 +56,16 @@ async def run_integration_test(): 2. Create work item 1 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 + 5. Find or create an "Epic" work item type, and create an epic work item + 6. Update work item 2 to be under the epic + 7. List all epics (work items of the "Epic" type) 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 + 13. Delete work items + 14. Delete project """ config = get_config() unique_id = uuid.uuid4().hex[:6] @@ -131,14 +131,41 @@ async def run_integration_test(): ) 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...") + # 5. Find or create an "Epic" work item type, and create an epic work item + print("Finding or creating 'Epic' work item type...") + created_workspace_epic_type = False + + project_types_result = await client.call_tool("list_work_item_types", {"project_id": project_id}) + project_types = extract_result(project_types_result) + epic_type = next((t for t in project_types if t.get("name", "").lower() == "epic"), None) + + if epic_type is None: + workspace_types_result = await client.call_tool("list_work_item_types", {}) + workspace_types = extract_result(workspace_types_result) + if workspace_types: + new_type_result = await client.call_tool("create_work_item_type", {"name": "Epic"}) + epic_type = extract_result(new_type_result) + created_workspace_epic_type = True + await client.call_tool( + "import_work_item_types_to_project", + {"project_id": project_id, "work_item_type_ids": [epic_type["id"]]}, + ) + else: + new_type_result = await client.call_tool( + "create_work_item_type", {"name": "Epic", "project_id": project_id} + ) + epic_type = extract_result(new_type_result) + + epic_type_id = epic_type["id"] + print(f"Using 'Epic' work item type: {epic_type_id}") + print("Creating epic...") epic_result = await client.call_tool( - "create_epic", + "create_work_item", { "project_id": project_id, "name": f"Epic {unique_id}", + "type_id": epic_type_id, }, ) @@ -163,12 +190,13 @@ async def run_integration_test(): # 7. List all epics print("Listing epics in project...") epics_result = await client.call_tool( - "list_epics", + "list_work_items", { "project_id": project_id, + "pql": f'type = "{epic_type_id}"', }, ) - epics = extract_result(epics_result) + epics = extract_result(epics_result)["results"] print(f"Epics in project: {[e['id'] for e in epics]}") # 8. Create a milestone and associate it with the project and work items @@ -231,11 +259,16 @@ async def run_integration_test(): # 9. Delete epic print("Deleting epic...") await client.call_tool( - "delete_epic", - {"project_id": project_id, "epic_id": epic_id}, + "delete_work_item", + {"project_id": project_id, "work_item_id": epic_id}, ) print("Deleted epic") + if created_workspace_epic_type: + print("Deleting workspace-level 'Epic' work item type...") + await client.call_tool("delete_work_item_type", {"work_item_type_id": epic_type_id}) + print("Deleted workspace-level 'Epic' work item type") + # 10. Delete project print("Deleting project...") await client.call_tool("delete_project", {"project_id": project_id}) @@ -305,6 +338,8 @@ def test_full_integration(): "retrieve_work_item_type", "update_work_item_type", "delete_work_item_type", + "import_work_item_types_to_project", + "resolve_work_item_type", # Work log tools "list_work_logs", "create_work_log", @@ -359,12 +394,6 @@ 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", ] From 657d69923fe43a57c2c2e30e976b312d669eeb76 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 17:22:00 +0530 Subject: [PATCH 2/2] refactor: remove level parameter from work item type registration functions --- plane_mcp/tools/work_item_types.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index fec4a88..fca9df0 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -41,7 +41,6 @@ def create_work_item_type( description: str | None = None, project_ids: list[str] | None = None, is_active: bool | None = None, - level: int | None = None, external_source: str | None = None, external_id: str | None = None, ) -> WorkItemType: @@ -54,7 +53,6 @@ def create_work_item_type( description: Work item type description project_ids: List of project IDs this type applies to is_active: Whether the type is active - level: Hierarchy level (workspace-level types only; ignored for project-level types) external_source: External system source name external_id: External system identifier @@ -68,7 +66,6 @@ def create_work_item_type( description=description, project_ids=project_ids, is_active=is_active, - level=level, external_source=external_source, external_id=external_id, ) @@ -103,7 +100,6 @@ def import_work_item_types_to_project( def resolve_work_item_type( project_id: str, name: str, - level: int | None = None, ) -> WorkItemType: """ Find a work item type by name for a project, create it if missing, and @@ -124,9 +120,6 @@ def resolve_work_item_type( Args: project_id: UUID of the project the type must be usable in name: Work item type name, e.g. "Epic" or "Initiative" - level: Hierarchy level to assign ONLY when creating a new workspace-level type. - Ignored if the type already exists (use update_work_item_type to change - an existing type's level). Has no effect on project-level types (Mode B). Returns: The WorkItemType. Its `id` is the `type_id` for create_work_item. @@ -150,7 +143,7 @@ def resolve_work_item_type( ) if at_workspace is None: at_workspace = client.workspace_work_item_types.create( - workspace_slug=workspace_slug, data=CreateWorkItemType(name=name, level=level) + workspace_slug=workspace_slug, data=CreateWorkItemType(name=name) ) client.work_item_types.import_to_project( workspace_slug=workspace_slug, @@ -217,7 +210,6 @@ def update_work_item_type( description: str | None = None, project_ids: list[str] | None = None, is_active: bool | None = None, - level: int | None = None, external_source: str | None = None, external_id: str | None = None, ) -> WorkItemType: @@ -231,9 +223,6 @@ def update_work_item_type( description: Work item type description project_ids: List of project IDs this type applies to is_active: Whether the type is active - level: Hierarchy level (workspace-level types only; ignored for project-level types). - Note: for Epic types (is_epic=true), only is_active can be changed — level - and all other fields are immutable and the server will reject the request. external_source: External system source name external_id: External system identifier @@ -247,7 +236,6 @@ def update_work_item_type( description=description, project_ids=project_ids, is_active=is_active, - level=level, external_source=external_source, external_id=external_id, )