Skip to content
Open
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,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 |
Expand Down
52 changes: 10 additions & 42 deletions plane_mcp/instructions.py
Original file line number Diff line number Diff line change
@@ -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=<epic name>).
3. List epics: list_work_items(project_id=project_id, pql='type = "<type id>"') (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=<epic work item id>.
6. List an epic's children: list_work_items(project_id=project_id, pql='childOf("<EPIC-IDENTIFIER>")'), where <EPIC-IDENTIFIER> 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 = "<type id>"').
4. Read / update / delete / nest: retrieve_work_item / update_work_item /
delete_work_item by work item id (set parent=<work item id> to nest).
5. List an epic's children: list_work_items(project_id, pql='childOf("<EPIC-IDENTIFIER>")')
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=<initiative name>).
4. List: list_work_items(project_id=project_id, pql='type = "<type id>"').
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
2 changes: 2 additions & 0 deletions plane_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
75 changes: 64 additions & 11 deletions plane_mcp/tools/initiatives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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 = \"<type id>\"'). "
"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

Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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)
147 changes: 147 additions & 0 deletions plane_mcp/tools/work_item_relation_definitions.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading