diff --git a/README.md b/README.md index 7943c19..68f684b 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,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/instructions.py b/plane_mcp/instructions.py index 7188cc1..4c42f3c 100644 --- a/plane_mcp/instructions.py +++ b/plane_mcp/instructions.py @@ -1,5 +1,3 @@ -"""Server-level instructions sent once to MCP clients (FastMCP `instructions` param).""" - SERVER_INSTRUCTIONS = """ ## Epics diff --git a/plane_mcp/tools/__init__.py b/plane_mcp/tools/__init__.py index 5908d63..443bbcf 100644 --- a/plane_mcp/tools/__init__.py +++ b/plane_mcp/tools/__init__.py @@ -18,6 +18,7 @@ from plane_mcp.tools.work_item_comments import register_work_item_comment_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 @@ -33,6 +34,7 @@ def register_tools(mcp: FastMCP) -> None: register_work_item_attachment_tools(mcp) register_work_item_comment_tools(mcp) register_work_item_link_tools(mcp) + register_work_item_relation_definition_tools(mcp) register_work_item_relation_tools(mcp) register_work_log_tools(mcp) register_cycle_tools(mcp) 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..af85244 --- /dev/null +++ b/plane_mcp/tools/work_item_relation_definitions.py @@ -0,0 +1,147 @@ +"""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, + PaginatedWorkItemRelationDefinitionResponse, + UpdateWorkItemRelationDefinition, + WorkItemRelationDefinition, +) +from plane.models.work_items import DependencyTypeEnum + +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, + ) -> dict[str, Any]: + """List every relation type usable with create_work_item_relation. + + 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 custom definitions to default/non-default only. + is_active: Filter custom definitions to active/inactive only. + + Returns: + 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] = [] + 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) + cursor = page.next_cursor + 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( + 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 index ee71696..8b05c75 100644 --- a/plane_mcp/tools/work_item_relations.py +++ b/plane_mcp/tools/work_item_relations.py @@ -1,117 +1,158 @@ -"""Work item relation-related tools for Plane MCP Server.""" +"""Work item relation tools for Plane MCP Server. -from typing import get_args +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.enums import WorkItemRelationTypeEnum from plane.models.work_items import ( - CreateWorkItemRelation, - RemoveWorkItemRelation, - WorkItemRelationResponse, + 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 all work item relation-related tools with the MCP server.""" + """Register work item relation 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. + ) -> 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 + 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 + dependencies: Built-in dependencies grouped by the six directions. + custom: Custom relations grouped by definition label. """ client, workspace_slug = get_plane_client_context() - return client.work_items.relations.list( + 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, - relation_type: str, - issues: list[str], - ) -> None: - """ - Create relations for a work item. + 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 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 + 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() - - # 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)}" + 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, + ), ) - 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, + 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_issue: str, + related_work_item_id: str, + is_dependency: bool, ) -> None: - """ - Remove a relation from a work item. + """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 work item - related_issue: UUID of the related work item to remove relation with + 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() - - data = RemoveWorkItemRelation(related_issue=related_issue) - - client.work_items.relations.delete( + 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, - data=data, + related_work_item_id=related_work_item_id, ) diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index ed16ac2..8a11e47 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -643,12 +643,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 diff --git a/tests/test_integration.py b/tests/test_integration.py index 3d9ca50..f99e0f9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -308,6 +308,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",