From 0cbae1cedb8c275706a3a61fa7ffff4f962c4180 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Thu, 11 Jun 2026 16:25:23 +0530 Subject: [PATCH 01/10] feat: add custom relation and dependency tools for work items --- plane_mcp/tools/__init__.py | 8 +- plane_mcp/tools/work_item_custom_relations.py | 104 ++++++++++++++ plane_mcp/tools/work_item_dependencies.py | 111 +++++++++++++++ .../tools/work_item_relation_definitions.py | 127 ++++++++++++++++++ plane_mcp/tools/work_item_relations.py | 117 ---------------- 5 files changed, 348 insertions(+), 119 deletions(-) create mode 100644 plane_mcp/tools/work_item_custom_relations.py create mode 100644 plane_mcp/tools/work_item_dependencies.py create mode 100644 plane_mcp/tools/work_item_relation_definitions.py delete mode 100644 plane_mcp/tools/work_item_relations.py diff --git a/plane_mcp/tools/__init__.py b/plane_mcp/tools/__init__.py index ce39fc0..f279c4f 100644 --- a/plane_mcp/tools/__init__.py +++ b/plane_mcp/tools/__init__.py @@ -16,9 +16,11 @@ from plane_mcp.tools.users import register_user_tools from plane_mcp.tools.work_item_activities import register_work_item_activity_tools from plane_mcp.tools.work_item_comments import register_work_item_comment_tools +from plane_mcp.tools.work_item_custom_relations import register_work_item_custom_relation_tools +from plane_mcp.tools.work_item_dependencies import register_work_item_dependency_tools from plane_mcp.tools.work_item_links import register_work_item_link_tools from plane_mcp.tools.work_item_properties import register_work_item_property_tools -from plane_mcp.tools.work_item_relations import register_work_item_relation_tools +from plane_mcp.tools.work_item_relation_definitions import register_work_item_relation_definition_tools from plane_mcp.tools.work_item_types import register_work_item_type_tools from plane_mcp.tools.work_items import register_work_item_tools from plane_mcp.tools.work_logs import register_work_log_tools @@ -32,7 +34,9 @@ def register_tools(mcp: FastMCP) -> None: register_work_item_activity_tools(mcp) register_work_item_comment_tools(mcp) register_work_item_link_tools(mcp) - register_work_item_relation_tools(mcp) + register_work_item_relation_definition_tools(mcp) + register_work_item_dependency_tools(mcp) + register_work_item_custom_relation_tools(mcp) register_work_log_tools(mcp) register_cycle_tools(mcp) register_user_tools(mcp) diff --git a/plane_mcp/tools/work_item_custom_relations.py b/plane_mcp/tools/work_item_custom_relations.py new file mode 100644 index 0000000..2afb3af --- /dev/null +++ b/plane_mcp/tools/work_item_custom_relations.py @@ -0,0 +1,104 @@ +"""Work item custom relation tools for Plane MCP Server.""" + +from fastmcp import FastMCP +from plane.models.work_items import ( + CreateWorkItemCustomRelation, + RemoveWorkItemCustomRelation, + WorkItemWithRelationType, +) + +from plane_mcp.client import get_plane_client_context + + +def register_work_item_custom_relation_tools(mcp: FastMCP) -> None: + """Register work item custom relation tools with the MCP server.""" + + @mcp.tool() + def list_work_item_custom_relations( + project_id: str, + work_item_id: str, + ) -> dict[str, list[WorkItemWithRelationType]]: + """List custom (definition-based) relations for a work item. + + Returns a dict keyed by the outward and inward labels of all active + workspace relation definitions. Each key maps to a list of work item + objects that hold that relation with this item. + + Use list_work_item_relation_definitions to discover available definitions + and their labels. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the work item. + + Returns: + Dict mapping relation label to list of WorkItemWithRelationType objects. + """ + client, workspace_slug = get_plane_client_context() + return client.work_items.custom_relations.list( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + ) + + @mcp.tool() + def create_work_item_custom_relation( + project_id: str, + work_item_id: str, + relation_definition_id: str, + relation_definition_type: str, + work_item_ids: list[str], + ) -> list[WorkItemWithRelationType]: + """Create one or more custom relations for a work item. + + Uses a workspace relation definition to establish the relation type. + relation_definition_type must match either the outward or inward label + of the specified definition — this controls directionality. + + Use list_work_item_relation_definitions to get definition IDs and their labels. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + relation_definition_id: UUID of the workspace relation definition. + relation_definition_type: The outward or inward label of the definition. + work_item_ids: UUIDs of target work items to create relations with. + + Returns: + List of created WorkItemWithRelationType objects. + """ + client, workspace_slug = get_plane_client_context() + return client.work_items.custom_relations.create( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=CreateWorkItemCustomRelation( + relation_definition_id=relation_definition_id, + relation_definition_type=relation_definition_type, + work_item_ids=work_item_ids, + ), + ) + + @mcp.tool() + def remove_work_item_custom_relation( + project_id: str, + work_item_id: str, + related_work_item_id: str, + ) -> None: + """Remove a custom relation between two work items. + + Removes any custom (definition-based) relation between the source and + target work item, regardless of direction. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + related_work_item_id: UUID of the related work item to remove the relation with. + """ + client, workspace_slug = get_plane_client_context() + client.work_items.custom_relations.remove( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=RemoveWorkItemCustomRelation(work_item_id=related_work_item_id), + ) diff --git a/plane_mcp/tools/work_item_dependencies.py b/plane_mcp/tools/work_item_dependencies.py new file mode 100644 index 0000000..76ac896 --- /dev/null +++ b/plane_mcp/tools/work_item_dependencies.py @@ -0,0 +1,111 @@ +"""Work item dependency tools for Plane MCP Server.""" + +from fastmcp import FastMCP +from plane.models.work_items import ( + CreateWorkItemDependency, + RemoveWorkItemDependency, + WorkItemDependencyResponse, + WorkItemWithRelationType, +) + +from plane_mcp.client import get_plane_client_context + +_DEPENDENCY_TYPES = ( + "blocking", + "blocked_by", + "start_before", + "start_after", + "finish_before", + "finish_after", +) + + +def register_work_item_dependency_tools(mcp: FastMCP) -> None: + """Register work item dependency tools with the MCP server.""" + + @mcp.tool() + def list_work_item_dependencies( + project_id: str, + work_item_id: str, + ) -> WorkItemDependencyResponse: + """List dependency relations for a work item, grouped by direction. + + Returns six groups: blocking, blocked_by, start_before, start_after, + finish_before, finish_after. Each group contains full work item objects + with a relation_type field indicating the direction. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the work item. + + Returns: + WorkItemDependencyResponse with a list of work items per dependency direction. + """ + client, workspace_slug = get_plane_client_context() + return client.work_items.dependencies.list( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + ) + + @mcp.tool() + def create_work_item_dependency( + project_id: str, + work_item_id: str, + relation_type: str, + work_item_ids: list[str], + ) -> list[WorkItemWithRelationType]: + """Create one or more dependency relations for a work item. + + relation_type controls directionality from this work item's perspective. + Allowed values: blocking, blocked_by, start_before, start_after, + finish_before, finish_after. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + relation_type: Dependency direction. One of: blocking, blocked_by, + start_before, start_after, finish_before, finish_after. + work_item_ids: UUIDs of target work items to create dependencies with. + + Returns: + List of created WorkItemWithRelationType objects. + """ + if relation_type not in _DEPENDENCY_TYPES: + raise ValueError( + f"Invalid relation_type '{relation_type}'. Must be one of: {_DEPENDENCY_TYPES}" + ) + client, workspace_slug = get_plane_client_context() + return client.work_items.dependencies.create( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=CreateWorkItemDependency( + relation_type=relation_type, # type: ignore[arg-type] + work_item_ids=work_item_ids, + ), + ) + + @mcp.tool() + def remove_work_item_dependency( + project_id: str, + work_item_id: str, + related_work_item_id: str, + ) -> None: + """Remove a dependency relation between two work items. + + Removes any dependency (in either direction) between the source and target + work item. The relation_type does not need to be specified. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + related_work_item_id: UUID of the related work item to remove the dependency with. + """ + client, workspace_slug = get_plane_client_context() + client.work_items.dependencies.remove( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=RemoveWorkItemDependency(work_item_id=related_work_item_id), + ) diff --git a/plane_mcp/tools/work_item_relation_definitions.py b/plane_mcp/tools/work_item_relation_definitions.py new file mode 100644 index 0000000..6c94817 --- /dev/null +++ b/plane_mcp/tools/work_item_relation_definitions.py @@ -0,0 +1,127 @@ +"""Work item relation definition tools for Plane MCP Server.""" + +from fastmcp import FastMCP +from plane.models.work_item_relation_definitions import ( + CreateWorkItemRelationDefinition, + UpdateWorkItemRelationDefinition, + WorkItemRelationDefinition, +) + +from plane_mcp.client import get_plane_client_context + + +def register_work_item_relation_definition_tools(mcp: FastMCP) -> None: + """Register work item relation definition tools with the MCP server.""" + + @mcp.tool() + def list_work_item_relation_definitions( + is_default: bool | None = None, + is_active: bool | None = None, + ) -> list[WorkItemRelationDefinition]: + """List workspace-level relation definitions. + + These definitions describe custom relation types (each with an outward + and inward label). Use definition IDs and labels when creating custom + relations between work items. + + Args: + is_default: Filter to default or non-default definitions only. + is_active: Filter to active or inactive definitions only. + + Returns: + List of WorkItemRelationDefinition objects. + """ + client, workspace_slug = get_plane_client_context() + return client.work_item_relation_definitions.list( + workspace_slug=workspace_slug, + is_default=is_default, + is_active=is_active, + ) + + @mcp.tool() + def create_work_item_relation_definition( + name: str, + outward: str | None = None, + inward: str | None = None, + is_active: bool | None = None, + color: str | None = None, + ) -> WorkItemRelationDefinition: + """Create a new workspace relation definition. + + A relation definition describes a named relationship type with an + outward label (how the source item describes the target) and an + inward label (how the target item describes the source). + + Args: + name: Unique name for this relation definition. + outward: Label describing the relation from the source item's perspective. + inward: Label describing the relation from the target item's perspective. + is_active: Whether this definition is active and available for use. + color: Hex color code for UI display. + + Returns: + Created WorkItemRelationDefinition object. + """ + client, workspace_slug = get_plane_client_context() + data = CreateWorkItemRelationDefinition( + name=name, + outward=outward, + inward=inward, + is_active=is_active, + color=color, + ) + return client.work_item_relation_definitions.create( + workspace_slug=workspace_slug, + data=data, + ) + + @mcp.tool() + def update_work_item_relation_definition( + definition_id: str, + name: str | None = None, + outward: str | None = None, + inward: str | None = None, + is_active: bool | None = None, + color: str | None = None, + ) -> WorkItemRelationDefinition: + """Update an existing workspace relation definition. + + Args: + definition_id: UUID of the relation definition to update. + name: New name for this definition. + outward: Updated outward label. + inward: Updated inward label. + is_active: Updated active status. + color: Updated hex color code. + + Returns: + Updated WorkItemRelationDefinition object. + """ + client, workspace_slug = get_plane_client_context() + data = UpdateWorkItemRelationDefinition( + name=name, + outward=outward, + inward=inward, + is_active=is_active, + color=color, + ) + return client.work_item_relation_definitions.update( + workspace_slug=workspace_slug, + definition_id=definition_id, + data=data, + ) + + @mcp.tool() + def delete_work_item_relation_definition( + definition_id: str, + ) -> None: + """Delete a workspace relation definition. + + Args: + definition_id: UUID of the relation definition to delete. + """ + client, workspace_slug = get_plane_client_context() + client.work_item_relation_definitions.delete( + workspace_slug=workspace_slug, + definition_id=definition_id, + ) diff --git a/plane_mcp/tools/work_item_relations.py b/plane_mcp/tools/work_item_relations.py deleted file mode 100644 index ee71696..0000000 --- a/plane_mcp/tools/work_item_relations.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Work item relation-related tools for Plane MCP Server.""" - -from typing import get_args - -from fastmcp import FastMCP -from plane.models.enums import WorkItemRelationTypeEnum -from plane.models.work_items import ( - CreateWorkItemRelation, - RemoveWorkItemRelation, - WorkItemRelationResponse, -) - -from plane_mcp.client import get_plane_client_context - - -def register_work_item_relation_tools(mcp: FastMCP) -> None: - """Register all work item relation-related tools with the MCP server.""" - - @mcp.tool() - def list_work_item_relations( - project_id: str, - work_item_id: str, - ) -> WorkItemRelationResponse: - """ - List relations for a work item. - - Args: - project_id: UUID of the project - work_item_id: UUID of the work item - - Returns: - WorkItemRelationResponse containing lists of related work items by relation type: - - blocking: Work items that are blocking this item - - blocked_by: Work items that this item is blocked by - - duplicate: Work items that are duplicates of this item - - relates_to: Work items that relate to this item - - start_after: Work items that start after this item - - start_before: Work items that start before this item - - finish_after: Work items that finish after this item - - finish_before: Work items that finish before this item - """ - client, workspace_slug = get_plane_client_context() - return client.work_items.relations.list( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - ) - - @mcp.tool() - def create_work_item_relation( - project_id: str, - work_item_id: str, - relation_type: str, - issues: list[str], - ) -> None: - """ - Create relations for a work item. - - Args: - project_id: UUID of the project - work_item_id: UUID of the work item - relation_type: Type of relationship. Must be one of: - - "relates_to" — general relationship (default when unsure) - - "blocking" — this item is blocking the listed items - - "blocked_by" — this item is blocked by the listed items - - "duplicate" — this item duplicates the listed items - - "start_after" — this item starts after the listed items - - "start_before" — this item starts before the listed items - - "finish_after" — this item finishes after the listed items - - "finish_before" — this item finishes before the listed items - issues: List of work item IDs to create relations with - """ - client, workspace_slug = get_plane_client_context() - - # Validate relation_type against allowed literal values - if relation_type not in get_args(WorkItemRelationTypeEnum): - raise ValueError( - f"Invalid relation_type '{relation_type}'. " f"Must be one of: {get_args(WorkItemRelationTypeEnum)}" - ) - validated_relation_type: WorkItemRelationTypeEnum = relation_type # type: ignore[assignment] - - data = CreateWorkItemRelation( - relation_type=validated_relation_type, - issues=issues, - ) - - client.work_items.relations.create( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - data=data, - ) - - @mcp.tool() - def remove_work_item_relation( - project_id: str, - work_item_id: str, - related_issue: str, - ) -> None: - """ - Remove a relation from a work item. - - Args: - project_id: UUID of the project - work_item_id: UUID of the work item - related_issue: UUID of the related work item to remove relation with - """ - client, workspace_slug = get_plane_client_context() - - data = RemoveWorkItemRelation(related_issue=related_issue) - - client.work_items.relations.delete( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - data=data, - ) From 9845750944af4349b4bc95d4e68ac4efe11b05cd Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 11:07:26 +0530 Subject: [PATCH 02/10] refactor: remove unused relation removal classes from work item tools. --- plane_mcp/tools/work_item_custom_relations.py | 3 +-- plane_mcp/tools/work_item_dependencies.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plane_mcp/tools/work_item_custom_relations.py b/plane_mcp/tools/work_item_custom_relations.py index 2afb3af..d0470c6 100644 --- a/plane_mcp/tools/work_item_custom_relations.py +++ b/plane_mcp/tools/work_item_custom_relations.py @@ -3,7 +3,6 @@ from fastmcp import FastMCP from plane.models.work_items import ( CreateWorkItemCustomRelation, - RemoveWorkItemCustomRelation, WorkItemWithRelationType, ) @@ -100,5 +99,5 @@ def remove_work_item_custom_relation( workspace_slug=workspace_slug, project_id=project_id, work_item_id=work_item_id, - data=RemoveWorkItemCustomRelation(work_item_id=related_work_item_id), + related_work_item_id=related_work_item_id, ) diff --git a/plane_mcp/tools/work_item_dependencies.py b/plane_mcp/tools/work_item_dependencies.py index 76ac896..5faf90b 100644 --- a/plane_mcp/tools/work_item_dependencies.py +++ b/plane_mcp/tools/work_item_dependencies.py @@ -3,7 +3,6 @@ from fastmcp import FastMCP from plane.models.work_items import ( CreateWorkItemDependency, - RemoveWorkItemDependency, WorkItemDependencyResponse, WorkItemWithRelationType, ) @@ -107,5 +106,5 @@ def remove_work_item_dependency( workspace_slug=workspace_slug, project_id=project_id, work_item_id=work_item_id, - data=RemoveWorkItemDependency(work_item_id=related_work_item_id), + related_work_item_id=related_work_item_id, ) From 751139a1e8788fd774ea2c6d8eac189c09e41cfb Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 14:54:02 +0530 Subject: [PATCH 03/10] chore: enhance work item relation definition retrieval with pagination support --- .../tools/work_item_relation_definitions.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/plane_mcp/tools/work_item_relation_definitions.py b/plane_mcp/tools/work_item_relation_definitions.py index 6c94817..1e3086f 100644 --- a/plane_mcp/tools/work_item_relation_definitions.py +++ b/plane_mcp/tools/work_item_relation_definitions.py @@ -3,6 +3,7 @@ from fastmcp import FastMCP from plane.models.work_item_relation_definitions import ( CreateWorkItemRelationDefinition, + PaginatedWorkItemRelationDefinitionResponse, UpdateWorkItemRelationDefinition, WorkItemRelationDefinition, ) @@ -18,11 +19,11 @@ def list_work_item_relation_definitions( is_default: bool | None = None, is_active: bool | None = None, ) -> list[WorkItemRelationDefinition]: - """List workspace-level relation definitions. + """List all workspace-level relation definitions. These definitions describe custom relation types (each with an outward and inward label). Use definition IDs and labels when creating custom - relations between work items. + relations between work items. All pages are fetched automatically. Args: is_default: Filter to default or non-default definitions only. @@ -32,11 +33,21 @@ def list_work_item_relation_definitions( List of WorkItemRelationDefinition objects. """ client, workspace_slug = get_plane_client_context() - return client.work_item_relation_definitions.list( - workspace_slug=workspace_slug, - is_default=is_default, - is_active=is_active, - ) + results: list[WorkItemRelationDefinition] = [] + cursor: str | None = None + while True: + page: PaginatedWorkItemRelationDefinitionResponse = client.work_item_relation_definitions.list( + workspace_slug=workspace_slug, + is_default=is_default, + is_active=is_active, + per_page=100, + cursor=cursor, + ) + results.extend(page.results) + if not page.next_page_results: + break + cursor = page.next_cursor + return results @mcp.tool() def create_work_item_relation_definition( From 3abec05d7556634ae4a3b62c4067ee022627c8ea Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 15 Jun 2026 17:12:33 +0530 Subject: [PATCH 04/10] docs: clarify usage of work item dependency and relation tools. --- plane_mcp/tools/work_item_dependencies.py | 22 +++++++++++++++---- .../tools/work_item_relation_definitions.py | 14 +++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/plane_mcp/tools/work_item_dependencies.py b/plane_mcp/tools/work_item_dependencies.py index 5faf90b..4994650 100644 --- a/plane_mcp/tools/work_item_dependencies.py +++ b/plane_mcp/tools/work_item_dependencies.py @@ -54,11 +54,25 @@ def create_work_item_dependency( relation_type: str, work_item_ids: list[str], ) -> list[WorkItemWithRelationType]: - """Create one or more dependency relations for a work item. + """Use this tool ONLY when the requested relationship exactly matches one of + these six built-in dependency types: - relation_type controls directionality from this work item's perspective. - Allowed values: blocking, blocked_by, start_before, start_after, - finish_before, finish_after. + - blocking + - blocked_by + - start_before + - start_after + - finish_before + - finish_after + + Do not infer, normalize, reinterpret, or map arbitrary natural-language + relationship names to a dependency type. + + If the requested relationship does not exactly match one of the six + allowed dependency types, do not use this tool. Instead: + + 1. Call list_work_item_relation_definitions. + 2. Match the user's intent against the available outward/inward labels. + 3. Use create_work_item_custom_relation. Args: project_id: UUID of the project. diff --git a/plane_mcp/tools/work_item_relation_definitions.py b/plane_mcp/tools/work_item_relation_definitions.py index 1e3086f..1d67253 100644 --- a/plane_mcp/tools/work_item_relation_definitions.py +++ b/plane_mcp/tools/work_item_relation_definitions.py @@ -21,9 +21,17 @@ def list_work_item_relation_definitions( ) -> list[WorkItemRelationDefinition]: """List all workspace-level relation definitions. - These definitions describe custom relation types (each with an outward - and inward label). Use definition IDs and labels when creating custom - relations between work items. All pages are fetched automatically. + These definitions describe workspace-level custom relation types. Each + definition contains an outward and inward label that describe the + relationship between two work items. + + Use this tool whenever the requested relationship does not exactly match + one of the six built-in dependency types. + + The returned definitions can be used to identify, validate, or create + custom relationships between work items. + + All pages are fetched automatically. Args: is_default: Filter to default or non-default definitions only. From 09d8165719f82942f4eebc10d54dabe0ed935cd5 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Tue, 16 Jun 2026 09:33:10 +0530 Subject: [PATCH 05/10] chore: merge work item relation tools and definition. --- README.md | 9 + plane_mcp/tools/__init__.py | 6 +- plane_mcp/tools/work_item_custom_relations.py | 103 ------------ plane_mcp/tools/work_item_dependencies.py | 124 -------------- .../tools/work_item_relation_definitions.py | 39 ++--- plane_mcp/tools/work_item_relations.py | 158 ++++++++++++++++++ tests/test_integration.py | 5 + 7 files changed, 194 insertions(+), 250 deletions(-) delete mode 100644 plane_mcp/tools/work_item_custom_relations.py delete mode 100644 plane_mcp/tools/work_item_dependencies.py create mode 100644 plane_mcp/tools/work_item_relations.py diff --git a/README.md b/README.md index f407630..bd23e33 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,15 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `create_work_item_relation` | Create relations for a work item | | `remove_work_item_relation` | Remove a relation from a work item | +### Work Item Relation Definitions + +| Tool Name | Description | +|-----------|-------------| +| `list_work_item_relation_definitions` | List workspace custom relation definitions | +| `create_work_item_relation_definition` | Create a workspace relation definition | +| `update_work_item_relation_definition` | Update a relation definition | +| `delete_work_item_relation_definition` | Delete a relation definition | + ### Work Item Activities | Tool Name | Description | diff --git a/plane_mcp/tools/__init__.py b/plane_mcp/tools/__init__.py index f279c4f..962c9f8 100644 --- a/plane_mcp/tools/__init__.py +++ b/plane_mcp/tools/__init__.py @@ -16,11 +16,10 @@ from plane_mcp.tools.users import register_user_tools from plane_mcp.tools.work_item_activities import register_work_item_activity_tools from plane_mcp.tools.work_item_comments import register_work_item_comment_tools -from plane_mcp.tools.work_item_custom_relations import register_work_item_custom_relation_tools -from plane_mcp.tools.work_item_dependencies import register_work_item_dependency_tools from plane_mcp.tools.work_item_links import register_work_item_link_tools from plane_mcp.tools.work_item_properties import register_work_item_property_tools from plane_mcp.tools.work_item_relation_definitions import register_work_item_relation_definition_tools +from plane_mcp.tools.work_item_relations import register_work_item_relation_tools from plane_mcp.tools.work_item_types import register_work_item_type_tools from plane_mcp.tools.work_items import register_work_item_tools from plane_mcp.tools.work_logs import register_work_log_tools @@ -35,8 +34,7 @@ def register_tools(mcp: FastMCP) -> None: register_work_item_comment_tools(mcp) register_work_item_link_tools(mcp) register_work_item_relation_definition_tools(mcp) - register_work_item_dependency_tools(mcp) - register_work_item_custom_relation_tools(mcp) + register_work_item_relation_tools(mcp) register_work_log_tools(mcp) register_cycle_tools(mcp) register_user_tools(mcp) diff --git a/plane_mcp/tools/work_item_custom_relations.py b/plane_mcp/tools/work_item_custom_relations.py deleted file mode 100644 index d0470c6..0000000 --- a/plane_mcp/tools/work_item_custom_relations.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Work item custom relation tools for Plane MCP Server.""" - -from fastmcp import FastMCP -from plane.models.work_items import ( - CreateWorkItemCustomRelation, - WorkItemWithRelationType, -) - -from plane_mcp.client import get_plane_client_context - - -def register_work_item_custom_relation_tools(mcp: FastMCP) -> None: - """Register work item custom relation tools with the MCP server.""" - - @mcp.tool() - def list_work_item_custom_relations( - project_id: str, - work_item_id: str, - ) -> dict[str, list[WorkItemWithRelationType]]: - """List custom (definition-based) relations for a work item. - - Returns a dict keyed by the outward and inward labels of all active - workspace relation definitions. Each key maps to a list of work item - objects that hold that relation with this item. - - Use list_work_item_relation_definitions to discover available definitions - and their labels. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the work item. - - Returns: - Dict mapping relation label to list of WorkItemWithRelationType objects. - """ - client, workspace_slug = get_plane_client_context() - return client.work_items.custom_relations.list( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - ) - - @mcp.tool() - def create_work_item_custom_relation( - project_id: str, - work_item_id: str, - relation_definition_id: str, - relation_definition_type: str, - work_item_ids: list[str], - ) -> list[WorkItemWithRelationType]: - """Create one or more custom relations for a work item. - - Uses a workspace relation definition to establish the relation type. - relation_definition_type must match either the outward or inward label - of the specified definition — this controls directionality. - - Use list_work_item_relation_definitions to get definition IDs and their labels. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the source work item. - relation_definition_id: UUID of the workspace relation definition. - relation_definition_type: The outward or inward label of the definition. - work_item_ids: UUIDs of target work items to create relations with. - - Returns: - List of created WorkItemWithRelationType objects. - """ - client, workspace_slug = get_plane_client_context() - return client.work_items.custom_relations.create( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - data=CreateWorkItemCustomRelation( - relation_definition_id=relation_definition_id, - relation_definition_type=relation_definition_type, - work_item_ids=work_item_ids, - ), - ) - - @mcp.tool() - def remove_work_item_custom_relation( - project_id: str, - work_item_id: str, - related_work_item_id: str, - ) -> None: - """Remove a custom relation between two work items. - - Removes any custom (definition-based) relation between the source and - target work item, regardless of direction. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the source work item. - related_work_item_id: UUID of the related work item to remove the relation with. - """ - client, workspace_slug = get_plane_client_context() - client.work_items.custom_relations.remove( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - related_work_item_id=related_work_item_id, - ) diff --git a/plane_mcp/tools/work_item_dependencies.py b/plane_mcp/tools/work_item_dependencies.py deleted file mode 100644 index 4994650..0000000 --- a/plane_mcp/tools/work_item_dependencies.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Work item dependency tools for Plane MCP Server.""" - -from fastmcp import FastMCP -from plane.models.work_items import ( - CreateWorkItemDependency, - WorkItemDependencyResponse, - WorkItemWithRelationType, -) - -from plane_mcp.client import get_plane_client_context - -_DEPENDENCY_TYPES = ( - "blocking", - "blocked_by", - "start_before", - "start_after", - "finish_before", - "finish_after", -) - - -def register_work_item_dependency_tools(mcp: FastMCP) -> None: - """Register work item dependency tools with the MCP server.""" - - @mcp.tool() - def list_work_item_dependencies( - project_id: str, - work_item_id: str, - ) -> WorkItemDependencyResponse: - """List dependency relations for a work item, grouped by direction. - - Returns six groups: blocking, blocked_by, start_before, start_after, - finish_before, finish_after. Each group contains full work item objects - with a relation_type field indicating the direction. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the work item. - - Returns: - WorkItemDependencyResponse with a list of work items per dependency direction. - """ - client, workspace_slug = get_plane_client_context() - return client.work_items.dependencies.list( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - ) - - @mcp.tool() - def create_work_item_dependency( - project_id: str, - work_item_id: str, - relation_type: str, - work_item_ids: list[str], - ) -> list[WorkItemWithRelationType]: - """Use this tool ONLY when the requested relationship exactly matches one of - these six built-in dependency types: - - - blocking - - blocked_by - - start_before - - start_after - - finish_before - - finish_after - - Do not infer, normalize, reinterpret, or map arbitrary natural-language - relationship names to a dependency type. - - If the requested relationship does not exactly match one of the six - allowed dependency types, do not use this tool. Instead: - - 1. Call list_work_item_relation_definitions. - 2. Match the user's intent against the available outward/inward labels. - 3. Use create_work_item_custom_relation. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the source work item. - relation_type: Dependency direction. One of: blocking, blocked_by, - start_before, start_after, finish_before, finish_after. - work_item_ids: UUIDs of target work items to create dependencies with. - - Returns: - List of created WorkItemWithRelationType objects. - """ - if relation_type not in _DEPENDENCY_TYPES: - raise ValueError( - f"Invalid relation_type '{relation_type}'. Must be one of: {_DEPENDENCY_TYPES}" - ) - client, workspace_slug = get_plane_client_context() - return client.work_items.dependencies.create( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - data=CreateWorkItemDependency( - relation_type=relation_type, # type: ignore[arg-type] - work_item_ids=work_item_ids, - ), - ) - - @mcp.tool() - def remove_work_item_dependency( - project_id: str, - work_item_id: str, - related_work_item_id: str, - ) -> None: - """Remove a dependency relation between two work items. - - Removes any dependency (in either direction) between the source and target - work item. The relation_type does not need to be specified. - - Args: - project_id: UUID of the project. - work_item_id: UUID of the source work item. - related_work_item_id: UUID of the related work item to remove the dependency with. - """ - client, workspace_slug = get_plane_client_context() - client.work_items.dependencies.remove( - workspace_slug=workspace_slug, - project_id=project_id, - work_item_id=work_item_id, - related_work_item_id=related_work_item_id, - ) diff --git a/plane_mcp/tools/work_item_relation_definitions.py b/plane_mcp/tools/work_item_relation_definitions.py index 1d67253..af85244 100644 --- a/plane_mcp/tools/work_item_relation_definitions.py +++ b/plane_mcp/tools/work_item_relation_definitions.py @@ -1,5 +1,7 @@ """Work item relation definition tools for Plane MCP Server.""" +from typing import Any, get_args + from fastmcp import FastMCP from plane.models.work_item_relation_definitions import ( CreateWorkItemRelationDefinition, @@ -7,6 +9,7 @@ UpdateWorkItemRelationDefinition, WorkItemRelationDefinition, ) +from plane.models.work_items import DependencyTypeEnum from plane_mcp.client import get_plane_client_context @@ -18,27 +21,22 @@ def register_work_item_relation_definition_tools(mcp: FastMCP) -> None: def list_work_item_relation_definitions( is_default: bool | None = None, is_active: bool | None = None, - ) -> list[WorkItemRelationDefinition]: - """List all workspace-level relation definitions. - - These definitions describe workspace-level custom relation types. Each - definition contains an outward and inward label that describe the - relationship between two work items. - - Use this tool whenever the requested relationship does not exactly match - one of the six built-in dependency types. + ) -> dict[str, Any]: + """List every relation type usable with create_work_item_relation. - The returned definitions can be used to identify, validate, or create - custom relationships between work items. - - All pages are fetched automatically. + Match the user's wording against an entry here before creating a relation. + built_in_dependencies are fixed scheduling/blocking types; custom_definitions + are workspace-specific, each with an outward and inward label. custom_definitions + are also what create/update/delete_work_item_relation_definition manage. Args: - is_default: Filter to default or non-default definitions only. - is_active: Filter to active or inactive definitions only. + is_default: Filter custom definitions to default/non-default only. + is_active: Filter custom definitions to active/inactive only. Returns: - List of WorkItemRelationDefinition objects. + built_in_dependencies: relation_type values for the dependency path. + custom_definitions: workspace definitions; use the id plus the matched + outward or inward label. """ client, workspace_slug = get_plane_client_context() results: list[WorkItemRelationDefinition] = [] @@ -52,10 +50,13 @@ def list_work_item_relation_definitions( cursor=cursor, ) results.extend(page.results) - if not page.next_page_results: - break cursor = page.next_cursor - return results + if not page.next_page_results or not cursor: + break + return { + "built_in_dependencies": list(get_args(DependencyTypeEnum)), + "custom_definitions": [d.model_dump() for d in results], + } @mcp.tool() def create_work_item_relation_definition( diff --git a/plane_mcp/tools/work_item_relations.py b/plane_mcp/tools/work_item_relations.py new file mode 100644 index 0000000..8b05c75 --- /dev/null +++ b/plane_mcp/tools/work_item_relations.py @@ -0,0 +1,158 @@ +"""Work item relation tools for Plane MCP Server. + +Consolidates the two relation systems behind one set of tools: + +- Built-in dependencies — six fixed directional types (blocking, blocked_by, + start_before, start_after, finish_before, finish_after). +- Custom relations — workspace-defined types created via + list/create_work_item_relation_definition, each with an outward/inward label. + +create_work_item_relation routes between them by which argument is supplied. +The LLM discovers both kinds in one place via list_work_item_relation_definitions +(built_in_dependencies + custom_definitions) and matches the user's wording to an +entry there, so a custom label like "dependent on" is never mistaken for the +built-in blocked_by. +""" + +from typing import Any, get_args + +from fastmcp import FastMCP +from plane.models.work_items import ( + CreateWorkItemCustomRelation, + CreateWorkItemDependency, + DependencyTypeEnum, + WorkItemWithRelationType, +) + +from plane_mcp.client import get_plane_client_context + +# Built-in dependency relation_type values (sourced from the SDK contract). +_DEPENDENCY_TYPES: tuple[str, ...] = get_args(DependencyTypeEnum) + + +def register_work_item_relation_tools(mcp: FastMCP) -> None: + """Register work item relation tools with the MCP server.""" + + @mcp.tool() + def list_work_item_relations( + project_id: str, + work_item_id: str, + ) -> dict[str, Any]: + """List every relation for a work item. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the work item. + + Returns: + dependencies: Built-in dependencies grouped by the six directions. + custom: Custom relations grouped by definition label. + """ + client, workspace_slug = get_plane_client_context() + dependencies = client.work_items.dependencies.list( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + ) + custom = client.work_items.custom_relations.list( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + ) + return { + "dependencies": dependencies.model_dump(), + "custom": {label: [item.model_dump() for item in items] for label, items in custom.items()}, + } + + @mcp.tool() + def create_work_item_relation( + project_id: str, + work_item_id: str, + work_item_ids: list[str], + relation_type: str | None = None, + relation_definition_id: str | None = None, + relation_definition_label: str | None = None, + ) -> list[WorkItemWithRelationType]: + """Relate a work item to one or more targets. + + Always call list_work_item_relation_definitions first and match the user's + wording to an entry there. If it is a built_in_dependencies value, pass it + as relation_type. If it is a custom_definitions entry, pass that + definition's id as relation_definition_id and the matched outward/inward + label as relation_definition_label (the label sets directionality). + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + work_item_ids: UUIDs of the target work items. + relation_type: A built_in_dependencies value, or None for a custom relation. + relation_definition_id: UUID of the relation definition (custom relations). + relation_definition_label: Definition's outward or inward label (custom relations). + + Returns: + List of created WorkItemWithRelationType objects. + """ + client, workspace_slug = get_plane_client_context() + if relation_type: + if relation_type not in _DEPENDENCY_TYPES: + raise ValueError( + f"relation_type must be one of {list(_DEPENDENCY_TYPES)}. For any " + "other relationship, pass relation_definition_id + " + "relation_definition_label from list_work_item_relation_definitions." + ) + return client.work_items.dependencies.create( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=CreateWorkItemDependency( + relation_type=relation_type, # type: ignore[arg-type] + work_item_ids=work_item_ids, + ), + ) + if relation_definition_id and relation_definition_label: + return client.work_items.custom_relations.create( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + data=CreateWorkItemCustomRelation( + relation_definition_id=relation_definition_id, + relation_definition_type=relation_definition_label, + work_item_ids=work_item_ids, + ), + ) + raise ValueError( + "Provide relation_type for a built-in dependency, or " + "relation_definition_id + relation_definition_label for a custom " + "relation (call list_work_item_relation_definitions to find one)." + ) + + @mcp.tool() + def remove_work_item_relation( + project_id: str, + work_item_id: str, + related_work_item_id: str, + is_dependency: bool, + ) -> None: + """Remove ONE relation between two work items. + + A built-in dependency and a custom relation are removed independently — + removing one leaves the other intact. Set is_dependency from the relation + the user named (see list_work_item_relations): True for a built-in + dependency (blocking, blocked_by, start/finish ordering), False for a + custom relation. + + Args: + project_id: UUID of the project. + work_item_id: UUID of the source work item. + related_work_item_id: UUID of the related work item. + is_dependency: True to remove a built-in dependency, False to remove a + custom relation. + """ + client, workspace_slug = get_plane_client_context() + remove = client.work_items.dependencies.remove if is_dependency else client.work_items.custom_relations.remove + remove( + workspace_slug=workspace_slug, + project_id=project_id, + work_item_id=work_item_id, + related_work_item_id=related_work_item_id, + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 9ff5635..127b6cb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -299,6 +299,11 @@ def test_full_integration(): "list_work_item_relations", "create_work_item_relation", "remove_work_item_relation", + # Work item relation definition tools + "list_work_item_relation_definitions", + "create_work_item_relation_definition", + "update_work_item_relation_definition", + "delete_work_item_relation_definition", # Work item type tools "list_work_item_types", "create_work_item_type", From bd920fd1aeaa1792c8a6c7dcfc9bf98d15386c19 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Tue, 16 Jun 2026 10:10:33 +0530 Subject: [PATCH 06/10] refactor: streamline initiative handling and enhance server instructions --- plane_mcp/instructions.py | 52 ++++----------------- plane_mcp/tools/initiatives.py | 75 +++++++++++++++++++++++++----- plane_mcp/tools/work_item_types.py | 10 ++++ plane_mcp/tools/work_items.py | 12 +++-- 4 files changed, 91 insertions(+), 58 deletions(-) diff --git a/plane_mcp/instructions.py b/plane_mcp/instructions.py index 9361cb8..4c42f3c 100644 --- a/plane_mcp/instructions.py +++ b/plane_mcp/instructions.py @@ -1,45 +1,13 @@ -"""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 = """ +SERVER_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. +There are no epic tools — an epic is a work item whose type is named "Epic". Work +items always belong to a project; ask which if one is not named. +1. type = resolve_work_item_type(project_id, "Epic") — type.id is the type_id. +2. Create: create_work_item(project_id, type_id=type.id, name=...). +3. List: list_work_items(project_id, pql='type = ""'). +4. Read / update / delete / nest: retrieve_work_item / update_work_item / + delete_work_item by work item id (set parent= to nest). +5. List an epic's children: list_work_items(project_id, pql='childOf("")') + using 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/tools/initiatives.py b/plane_mcp/tools/initiatives.py index 07c4787..f8c24bf 100644 --- a/plane_mcp/tools/initiatives.py +++ b/plane_mcp/tools/initiatives.py @@ -15,6 +15,18 @@ from plane_mcp.client import get_plane_client_context +def _require_native_initiatives(client: Any, workspace_slug: str, fallback: str) -> None: + """Raise a ToolError with fallback guidance when the initiatives feature is off. + + Native initiative endpoints only work when the workspace "initiatives" feature + is enabled. When it is off, initiatives are modeled as "Initiative" work items, + so every native initiative tool must redirect the caller to the work-item path. + """ + 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. {fallback}") + + def register_initiative_tools(mcp: FastMCP) -> None: """Register all initiative-related tools with the MCP server.""" @@ -30,8 +42,20 @@ def list_initiatives( Returns: List of Initiative objects + + Raises: + ToolError: if the initiatives feature is disabled. When disabled, + initiatives are "Initiative" work items — the error gives the steps. """ client, workspace_slug = get_plane_client_context() + _require_native_initiatives( + client, + workspace_slug, + 'Initiatives are stored as "Initiative" work items here. List them with ' + 'resolve_work_item_type(project_id, "Initiative"), then ' + "list_work_items(project_id, pql='type = \"\"'). " + "Work items belong to a project — ask which if not named.", + ) response: PaginatedInitiativeResponse = client.initiatives.list(workspace_slug=workspace_slug, params=params) return response.results @@ -63,20 +87,19 @@ def create_initiative( 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). + When disabled, create an "Initiative" work item instead — the error + message gives the exact steps. """ 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)})." - ) + _require_native_initiatives( + client, + workspace_slug, + f'Create {name!r} as an "Initiative" work item instead:\n' + "1. Work items belong to a project — if not named, ask the user which project to use.\n" + '2. type = resolve_work_item_type(project_id, "Initiative") — finds or creates the type automatically.\n' + f"3. create_work_item(project_id=project_id, type_id=type.id, name={name!r}).", + ) data = CreateInitiative( name=name, @@ -100,8 +123,18 @@ def retrieve_initiative(initiative_id: str) -> Initiative: Returns: Initiative object + + Raises: + ToolError: if the initiatives feature is disabled. When disabled, the + initiative is an "Initiative" work item — the error gives the steps. """ client, workspace_slug = get_plane_client_context() + _require_native_initiatives( + client, + workspace_slug, + 'This initiative is an "Initiative" work item. Retrieve it with ' + "retrieve_work_item(project_id, work_item_id) instead.", + ) return client.initiatives.retrieve(workspace_slug=workspace_slug, initiative_id=initiative_id) @mcp.tool() @@ -130,8 +163,18 @@ def update_initiative( Returns: Updated Initiative object + + Raises: + ToolError: if the initiatives feature is disabled. When disabled, the + initiative is an "Initiative" work item — the error gives the steps. """ client, workspace_slug = get_plane_client_context() + _require_native_initiatives( + client, + workspace_slug, + 'This initiative is an "Initiative" work item. Update it with ' + "update_work_item(project_id, work_item_id, ...) instead.", + ) data = UpdateInitiative( name=name, @@ -152,6 +195,16 @@ def delete_initiative(initiative_id: str) -> None: Args: initiative_id: UUID of the initiative + + Raises: + ToolError: if the initiatives feature is disabled. When disabled, the + initiative is an "Initiative" work item — the error gives the steps. """ client, workspace_slug = get_plane_client_context() + _require_native_initiatives( + client, + workspace_slug, + 'This initiative is an "Initiative" work item. Delete it with ' + "delete_work_item(project_id, work_item_id) instead.", + ) client.initiatives.delete(workspace_slug=workspace_slug, initiative_id=initiative_id) diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index fca9df0..06d363c 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -47,6 +47,9 @@ def create_work_item_type( """ Create a new work item type. + To get a usable type for a project (e.g. "Epic"), prefer resolve_work_item_type, + which finds-or-creates at the correct scope and never duplicates. + Args: name: Work item type name project_id: UUID of the project. Omit for workspace-level type. @@ -87,6 +90,9 @@ def import_work_item_types_to_project( Imports one or more workspace-scoped work item types into a project so that they become available for use within that project. + For the common case of getting one named type usable in a project, prefer + resolve_work_item_type, which finds-or-creates and imports in one step. + Args: project_id: UUID of the project work_item_type_ids: List of workspace-level work item type UUIDs to import @@ -117,6 +123,10 @@ def resolve_work_item_type( Matching is exact (case-sensitive, whitespace-stripped); an existing type is never duplicated. + Prefer this 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. + Args: project_id: UUID of the project the type must be usable in name: Work item type name, e.g. "Epic" or "Initiative" diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 6e4b8c8..331798d 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -58,7 +58,9 @@ def list_work_items( fields: Sparse fieldset — id, name, sequence_id, priority, state, project, assignees, labels, type_id, start_date, target_date, created_at, updated_at, created_by, is_draft. Use `project` not - `project_id`. + `project_id`. Fields you omit come back null — to read an item's + type, include type_id (a null type_id on an omitted field does + NOT mean the item is untyped). external_id / external_source: Filter by external system. Returns: @@ -665,12 +667,12 @@ def search_work_items( """ Search work items by text across a workspace. - Use this for free-text name/description search. For structured - filtering (priority, state, assignee, dates, etc.) use - `list_work_items` with a PQL expression. + Matches on work item name, sequence id, and project identifier (not + description). For structured filtering (priority, state, assignee, + dates, etc.) use `list_work_items` with a PQL expression. Args: - query: Free-text search string across work item name and description + query: Free-text string matched against name, sequence id, and project identifier expand: Comma-separated list of related fields to expand in response fields: Comma-separated list of fields to include in response external_id: External system identifier for filtering From 1492592bf7a5672f1de687edb914ee1400d34089 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Wed, 17 Jun 2026 11:06:54 +0530 Subject: [PATCH 07/10] feat: add configurable logging for user info (PII) in logs --- .env.test | 4 ++++ CLAUDE.md | 1 + README.md | 10 ++++++++++ plane_mcp/__main__.py | 26 ++++++++++++++++++++++---- plane_mcp/auth/plane_oauth_provider.py | 10 +++++++++- plane_mcp/middleware.py | 21 +++++++++++++++++++++ plane_mcp/server.py | 8 ++++---- 7 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 plane_mcp/middleware.py diff --git a/.env.test b/.env.test index 4cff693..4112a8a 100644 --- a/.env.test +++ b/.env.test @@ -20,6 +20,10 @@ PLANE_TEST_MCP_URL=http://localhost:8211 # Use when reverse-proxying alongside other apps. # MCP_PATH_PREFIX= +# Optional: Logging — when true, include user info (PII such as the display +# name) in logs alongside the opaque user id. Defaults to false. +# LOG_USER_INFO=false + # --------------------------------------------------------------------------- # Redis / Token storage (HTTP / SSE modes) # --------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 5332d8e..2dceb89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,3 +86,4 @@ Integration tests in `tests/test_integration.py` use `FastMCP.Client` with `Stre | `PLANE_INTERNAL_BASE_URL` | http/sse (optional) | Internal URL for server-to-server calls | | `REDIS_HOST` / `REDIS_PORT` | http/sse (optional) | Token storage (falls back to in-memory) | | `PLANE_OAUTH_PROVIDER_*` | http/sse OAuth | OAuth client credentials and base URL | +| `LOG_USER_INFO` | all (optional, default: false) | When `true`, include user info (PII such as display name) in logs alongside the opaque user id | diff --git a/README.md b/README.md index 7943c19..d957d2a 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,16 @@ export PLANE_WORKSPACE_SLUG="your-workspace-slug" **Note**: For remote HTTP transports (OAuth or PAT), authentication is handled via the connection method (OAuth flow or PAT headers) and does not require these environment variables. +### Logging + +The server emits structured JSON logs. Each tool call is logged with its tool name, duration, status, and (when available) the opaque user id and workspace slug. + +- `LOG_USER_INFO`: When `true`, include user info (PII such as the display name) in logs alongside the opaque user id. Defaults to `false` so PII is never logged unless explicitly opted in. Only the OAuth and PAT (header) HTTP transports carry a display name; stdio is unaffected. + +```bash +export LOG_USER_INFO="true" +``` + ## Available Tools The server provides comprehensive tools for interacting with Plane. All tools use Pydantic models from the Plane SDK for type safety and validation. diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index c20673f..cb42499 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -16,26 +16,38 @@ from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp +LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true" + class UserContextFilter(logging.Filter): - """Attach the authenticated user's id to every log record. + """Attach authenticated user/workspace context to every log record. Pulls the current request's access token via FastMCP's dependency, which - returns None (never raises) outside a request context — so startup logs and - stdio mode simply carry no user info. Only the opaque user id is recorded; - PII such as the display name / email is intentionally never logged. + returns None (never raises) outside a request context — so startup logs fall + back to environment config and otherwise carry no user info. + + Always logs the opaque user id (sub claim) and the workspace slug; neither is + PII. The display name IS PII and is only included when LOG_USER_INFO=true. """ def filter(self, record: logging.LogRecord) -> bool: user_id = None + display_name = None + workspace_slug = None try: token = get_access_token() if token: user_id = token.claims.get("sub") + workspace_slug = token.claims.get("workspace_slug") + if LOG_USER_INFO: + display_name = token.claims.get("display_name") except Exception as exc: # Never let logging enrichment break a request, but leave a signal. record.user_context_enrichment_error = type(exc).__name__ record.user_id = user_id + record.display_name = display_name + # stdio mode has no token; fall back to the configured workspace. + record.workspace_slug = workspace_slug or os.getenv("PLANE_WORKSPACE_SLUG") or None return True @@ -52,6 +64,12 @@ def format(self, record: logging.LogRecord) -> str: user_id = getattr(record, "user_id", None) if user_id: log_entry["user_id"] = user_id + workspace_slug = getattr(record, "workspace_slug", None) + if workspace_slug: + log_entry["workspace_slug"] = workspace_slug + display_name = getattr(record, "display_name", None) + if display_name: + log_entry["display_name"] = display_name err = getattr(record, "user_context_enrichment_error", None) if err: log_entry["user_context_enrichment_error"] = err diff --git a/plane_mcp/auth/plane_oauth_provider.py b/plane_mcp/auth/plane_oauth_provider.py index 0320d9c..12b8637 100644 --- a/plane_mcp/auth/plane_oauth_provider.py +++ b/plane_mcp/auth/plane_oauth_provider.py @@ -40,6 +40,10 @@ logger = get_logger(__name__) +# When true, user info (PII such as the display name) is included in logs. +# Defaults to false so PII is never logged unless explicitly opted in. +LOG_USER_INFO: bool = os.getenv("LOG_USER_INFO", "").lower() == "true" + DEFAULT_PLANE_BASE_URL = "https://api.plane.so" @@ -158,7 +162,11 @@ async def verify_token(self, token: str) -> AccessToken | None: expires_at = int(time.time() + 3600) - logger.info(f"User: ({user.id}) - {user.display_name}") + # display_name is PII — only log it when explicitly opted in. + if LOG_USER_INFO: + logger.info(f"User verified: ({user.id}) - {user.display_name}") + else: + logger.info(f"User verified: ({user.id})") installations_response = await client.get( f"{base_url}/auth/o/app-installation/", diff --git a/plane_mcp/middleware.py b/plane_mcp/middleware.py new file mode 100644 index 0000000..fbb0a0a --- /dev/null +++ b/plane_mcp/middleware.py @@ -0,0 +1,21 @@ +"""Custom FastMCP middleware for the Plane MCP Server.""" + +from __future__ import annotations + +from fastmcp.server.middleware import MiddlewareContext +from fastmcp.server.middleware.logging import StructuredLoggingMiddleware + + +class PlaneLoggingMiddleware(StructuredLoggingMiddleware): + """StructuredLoggingMiddleware that also records the tool name.""" + + def _with_tool_name(self, context: MiddlewareContext, message: dict) -> dict: + if context.method == "tools/call": + message["tool"] = getattr(context.message, "name", "unknown") + return message + + def _create_after_message(self, context: MiddlewareContext, start_time: float) -> dict: + return self._with_tool_name(context, super()._create_after_message(context, start_time)) + + def _create_error_message(self, context: MiddlewareContext, start_time: float, error: Exception) -> dict: + return self._with_tool_name(context, super()._create_error_message(context, start_time, error)) diff --git a/plane_mcp/server.py b/plane_mcp/server.py index 8d4be79..768f758 100644 --- a/plane_mcp/server.py +++ b/plane_mcp/server.py @@ -5,11 +5,11 @@ import os from fastmcp import FastMCP -from fastmcp.server.middleware.logging import StructuredLoggingMiddleware from mcp.types import Icon from plane_mcp.auth import PlaneHeaderAuthProvider, PlaneOAuthProvider from plane_mcp.instructions import SERVER_INSTRUCTIONS +from plane_mcp.middleware import PlaneLoggingMiddleware from plane_mcp.storage import build_token_store from plane_mcp.tools import register_tools @@ -47,7 +47,7 @@ def get_oauth_mcp(base_path: str = "/") -> FastMCP: ], ), ) - oauth_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True)) + oauth_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True)) register_tools(oauth_mcp) return oauth_mcp @@ -60,7 +60,7 @@ def get_header_mcp(): required_scopes=["read", "write"], ), ) - header_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True)) + header_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True)) register_tools(header_mcp) return header_mcp @@ -70,6 +70,6 @@ def get_stdio_mcp(): "Plane MCP Server (stdio)", instructions=SERVER_INSTRUCTIONS, ) - stdio_mcp.add_middleware(StructuredLoggingMiddleware(include_payloads=True)) + stdio_mcp.add_middleware(PlaneLoggingMiddleware(include_payloads=True)) register_tools(stdio_mcp) return stdio_mcp From f2694df7ded316f10f913b60afe6ad0c406359c9 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Wed, 17 Jun 2026 17:28:22 +0530 Subject: [PATCH 08/10] chore: flatten logs --- plane_mcp/__main__.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index cb42499..176b611 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -59,8 +59,18 @@ def format(self, record: logging.LogRecord) -> str: "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, - "message": record.getMessage(), } + # The logging middleware emits a JSON object as its log message. Promote + # those keys to top-level fields (event, method, tool, duration_ms, ...) + raw_message = record.getMessage() + try: + parsed = json.loads(raw_message) + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict): + log_entry.update(parsed) + else: + log_entry["message"] = raw_message user_id = getattr(record, "user_id", None) if user_id: log_entry["user_id"] = user_id @@ -74,10 +84,8 @@ def format(self, record: logging.LogRecord) -> str: if err: log_entry["user_context_enrichment_error"] = err if record.exc_info and record.exc_info[1]: - log_entry["error"] = { - "type": type(record.exc_info[1]).__name__, - "message": str(record.exc_info[1]), - } + log_entry["error"] = str(record.exc_info[1]) + log_entry["error_type"] = type(record.exc_info[1]).__name__ return json.dumps(log_entry) From aa41188e7685aa874e7873d9b4bf556fbe5b70d9 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Wed, 17 Jun 2026 17:31:11 +0530 Subject: [PATCH 09/10] chore: update plane-sdk dependency to version 0.2.17 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8cfcf19..4ae1426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ keywords = ["mcp", "plane", "fastmcp", "ai", "automation"] dependencies = [ "fastmcp==3.2.0", - "plane-sdk==0.2.16", + "plane-sdk==0.2.17", "py-key-value-aio[redis]>=0.4.4,<0.5.0", "mcp==1.26.0", "PyJWT>=2.12.0", From 5aa19b154fb4a81c9ff8453b398f268c9b00ae33 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Wed, 17 Jun 2026 17:32:36 +0530 Subject: [PATCH 10/10] docs: clarify documentation for grouped work item counts in register_work_item_tools function --- plane_mcp/tools/work_items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 8a11e47..c472649 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -161,7 +161,7 @@ def count_work_items( sub_grouped_by: The sub_group_by field used (null if none). total_count: Total matching work items. grouped_counts: Dict of group_key → {count} or - {total_count, sub_grouped_counts} when sub_group_by is set. + {count, sub_grouped_counts} when sub_group_by is set. Keys are UUIDs for FK fields, plain strings for priority/state__group, ISO dates for target_date/start_date, "None" for unset values. """