diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index c417134..7772b8a 100644 --- a/plane_mcp/tools/cycles.py +++ b/plane_mcp/tools/cycles.py @@ -1,5 +1,6 @@ """Cycle-related tools for Plane MCP Server.""" +import json from typing import Any from fastmcp import FastMCP @@ -207,6 +208,16 @@ def add_work_items_to_cycle( cycle_id: UUID of the cycle issue_ids: List of work item IDs to add to the cycle """ + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(issue_ids, str): + try: + issue_ids = json.loads(issue_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"issue_ids must be a JSON array string or a list, got: {issue_ids!r}" + ) from e + if not isinstance(issue_ids, list) or any(not isinstance(i, str) for i in issue_ids): + raise ValueError("issue_ids must be a list[str] or a JSON array string of strings") client, workspace_slug = get_plane_client_context() client.cycles.add_work_items( workspace_slug=workspace_slug, diff --git a/plane_mcp/tools/epics.py b/plane_mcp/tools/epics.py index 4f0f42b..b613820 100644 --- a/plane_mcp/tools/epics.py +++ b/plane_mcp/tools/epics.py @@ -1,4 +1,5 @@ """Epic-related tools for Plane MCP Server.""" +import json from typing import get_args from fastmcp import FastMCP @@ -18,7 +19,9 @@ def register_epic_tools(mcp: FastMCP) -> None: """Register all epic-related tools with the MCP server.""" - def _get_epic_work_item_type(client: PlaneClient, workspace_slug: str, project_id: str) -> WorkItemType | None: + def _get_epic_work_item_type( + client: PlaneClient, workspace_slug: str, project_id: str + ) -> WorkItemType | None: """Helper function to get the work item type ID for epics.""" response = client.work_item_types.list( workspace_slug=workspace_slug, @@ -122,6 +125,30 @@ def create_epic( if epic_type is None: raise ValueError("No work item type with is_epic=True found in the project") + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(assignees, str): + try: + assignees = json.loads(assignees) + except json.JSONDecodeError as e: + raise ValueError( + f"assignees must be a JSON array string or a list, got: {assignees!r}" + ) from e + if isinstance(labels, str): + try: + labels = json.loads(labels) + except json.JSONDecodeError as e: + raise ValueError( + f"labels must be a JSON array string or a list, got: {labels!r}" + ) from e + if assignees is not None and ( + not isinstance(assignees, list) or any(not isinstance(i, str) for i in assignees) + ): + raise ValueError("assignees must be a list[str] or a JSON array string of strings") + if labels is not None and ( + not isinstance(labels, list) or any(not isinstance(i, str) for i in labels) + ): + raise ValueError("labels must be a list[str] or a JSON array string of strings") + data = CreateWorkItem( name=name, assignees=assignees, @@ -205,6 +232,30 @@ def update_epic( raise ValueError(f"Invalid priority '{priority}'. Must be one of: {valid_priorities}") validated_priority: PriorityEnum | None = priority # type: ignore[assignment] + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(assignees, str): + try: + assignees = json.loads(assignees) + except json.JSONDecodeError as e: + raise ValueError( + f"assignees must be a JSON array string or a list, got: {assignees!r}" + ) from e + if isinstance(labels, str): + try: + labels = json.loads(labels) + except json.JSONDecodeError as e: + raise ValueError( + f"labels must be a JSON array string or a list, got: {labels!r}" + ) from e + if assignees is not None and ( + not isinstance(assignees, list) or any(not isinstance(i, str) for i in assignees) + ): + raise ValueError("assignees must be a list[str] or a JSON array string of strings") + if labels is not None and ( + not isinstance(labels, list) or any(not isinstance(i, str) for i in labels) + ): + raise ValueError("labels must be a list[str] or a JSON array string of strings") + data = UpdateWorkItem( name=name, assignees=assignees, diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py index 8012f71..3ec8d67 100644 --- a/plane_mcp/tools/milestones.py +++ b/plane_mcp/tools/milestones.py @@ -1,5 +1,6 @@ """Milestone-related tools for Plane MCP Server.""" +import json from typing import Any from fastmcp import FastMCP @@ -157,6 +158,16 @@ def add_work_items_to_milestone( milestone_id: UUID of the milestone issue_ids: List of work item IDs to add to the milestone """ + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(issue_ids, str): + try: + issue_ids = json.loads(issue_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"issue_ids must be a JSON array string or a list, got: {issue_ids!r}" + ) from e + if not isinstance(issue_ids, list) or any(not isinstance(i, str) for i in issue_ids): + raise ValueError("issue_ids must be a list[str] or a JSON array string of strings") client, workspace_slug = get_plane_client_context() client.milestones.add_work_items( workspace_slug=workspace_slug, @@ -179,6 +190,16 @@ def remove_work_items_from_milestone( milestone_id: UUID of the milestone issue_ids: List of work item IDs to remove from the milestone """ + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(issue_ids, str): + try: + issue_ids = json.loads(issue_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"issue_ids must be a JSON array string or a list, got: {issue_ids!r}" + ) from e + if not isinstance(issue_ids, list) or any(not isinstance(i, str) for i in issue_ids): + raise ValueError("issue_ids must be a list[str] or a JSON array string of strings") client, workspace_slug = get_plane_client_context() client.milestones.remove_work_items( workspace_slug=workspace_slug, diff --git a/plane_mcp/tools/modules.py b/plane_mcp/tools/modules.py index a15ffb6..3caeab1 100644 --- a/plane_mcp/tools/modules.py +++ b/plane_mcp/tools/modules.py @@ -1,5 +1,6 @@ """Module-related tools for Plane MCP Server.""" +import json from typing import Any, get_args from fastmcp import FastMCP @@ -81,6 +82,19 @@ def create_module( status if status in get_args(ModuleStatusEnum) else None # type: ignore[assignment] ) + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(members, str): + try: + members = json.loads(members) + except json.JSONDecodeError as e: + raise ValueError( + f"members must be a JSON array string or a list, got: {members!r}" + ) from e + if members is not None and ( + not isinstance(members, list) or any(not isinstance(i, str) for i in members) + ): + raise ValueError("members must be a list[str] or a JSON array string of strings") + data = CreateModule( name=name, description=description, @@ -156,6 +170,19 @@ def update_module( status if status in get_args(ModuleStatusEnum) else None # type: ignore[assignment] ) + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(members, str): + try: + members = json.loads(members) + except json.JSONDecodeError as e: + raise ValueError( + f"members must be a JSON array string or a list, got: {members!r}" + ) from e + if members is not None and ( + not isinstance(members, list) or any(not isinstance(i, str) for i in members) + ): + raise ValueError("members must be a list[str] or a JSON array string of strings") + data = UpdateModule( name=name, description=description, @@ -224,6 +251,16 @@ def add_work_items_to_module( module_id: UUID of the module issue_ids: List of work item IDs to add to the module """ + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(issue_ids, str): + try: + issue_ids = json.loads(issue_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"issue_ids must be a JSON array string or a list, got: {issue_ids!r}" + ) from e + if not isinstance(issue_ids, list) or any(not isinstance(i, str) for i in issue_ids): + raise ValueError("issue_ids must be a list[str] or a JSON array string of strings") client, workspace_slug = get_plane_client_context() client.modules.add_work_items( workspace_slug=workspace_slug, diff --git a/plane_mcp/tools/work_item_properties.py b/plane_mcp/tools/work_item_properties.py index 484cc00..f9410d0 100644 --- a/plane_mcp/tools/work_item_properties.py +++ b/plane_mcp/tools/work_item_properties.py @@ -1,5 +1,6 @@ """Work item property-related tools for Plane MCP Server.""" +import json from typing import Any from fastmcp import FastMCP @@ -74,14 +75,16 @@ def create_work_item_property( project_id: UUID of the project type_id: UUID of the work item type display_name: Display name for the property - property_type: Type of property (TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE) + property_type: Type of property + (TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE) relation_type: Relation type (ISSUE, USER) - required for RELATION properties description: Property description is_required: Whether the property is required default_value: Default value(s) for the property settings: Settings dictionary - required for TEXT and DATETIME properties For TEXT: {"display_format": "single-line"|"multi-line"|"readonly"} - For DATETIME: {"display_format": "MMM dd, yyyy"|"dd/MM/yyyy"|"MM/dd/yyyy"|"yyyy/MM/dd"} + For DATETIME: {"display_format": "MMM dd, yyyy"|"dd/MM/yyyy"|"MM/dd/yyyy"| + "yyyy/MM/dd"} is_active: Whether the property is active is_multi: Whether the property supports multiple values validation_rules: Validation rules dictionary @@ -115,6 +118,20 @@ def create_work_item_property( if options: processed_options = [CreateWorkItemPropertyOption(**opt) for opt in options] + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(default_value, str): + try: + default_value = json.loads(default_value) + except json.JSONDecodeError as e: + raise ValueError( + f"default_value must be a JSON array string or a list, got: {default_value!r}" + ) from e + if default_value is not None and ( + not isinstance(default_value, list) + or any(not isinstance(i, str) for i in default_value) + ): + raise ValueError("default_value must be a list[str] or a JSON array string of strings") + data = CreateWorkItemProperty( display_name=display_name, property_type=validated_property_type, @@ -188,14 +205,16 @@ def update_work_item_property( type_id: UUID of the work item type work_item_property_id: UUID of the property display_name: Display name for the property - property_type: Type of property (TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE) + property_type: Type of property + (TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE) relation_type: Relation type (ISSUE, USER) - required when updating to RELATION description: Property description is_required: Whether the property is required default_value: Default value(s) for the property settings: Settings dictionary - required when updating to TEXT or DATETIME For TEXT: {"display_format": "single-line"|"multi-line"|"readonly"} - For DATETIME: {"display_format": "MMM dd, yyyy"|"dd/MM/yyyy"|"MM/dd/yyyy"|"yyyy/MM/dd"} + For DATETIME: {"display_format": "MMM dd, yyyy"|"dd/MM/yyyy"|"MM/dd/yyyy"| + "yyyy/MM/dd"} is_active: Whether the property is active is_multi: Whether the property supports multiple values validation_rules: Validation rules dictionary @@ -225,6 +244,20 @@ def update_work_item_property( elif property_type == "DATETIME": processed_settings = DateAttributeSettings(**settings) + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(default_value, str): + try: + default_value = json.loads(default_value) + except json.JSONDecodeError as e: + raise ValueError( + f"default_value must be a JSON array string or a list, got: {default_value!r}" + ) from e + if default_value is not None and ( + not isinstance(default_value, list) + or any(not isinstance(i, str) for i in default_value) + ): + raise ValueError("default_value must be a list[str] or a JSON array string of strings") + data = UpdateWorkItemProperty( display_name=display_name, property_type=validated_property_type, diff --git a/plane_mcp/tools/work_item_relations.py b/plane_mcp/tools/work_item_relations.py index 898f01b..830b34e 100644 --- a/plane_mcp/tools/work_item_relations.py +++ b/plane_mcp/tools/work_item_relations.py @@ -1,5 +1,6 @@ """Work item relation-related tools for Plane MCP Server.""" +import json from typing import get_args from fastmcp import FastMCP @@ -73,6 +74,17 @@ def create_work_item_relation( ) validated_relation_type: WorkItemRelationTypeEnum = relation_type # type: ignore[assignment] + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(issues, str): + try: + issues = json.loads(issues) + except json.JSONDecodeError as e: + raise ValueError( + f"issues must be a JSON array string or a list, got: {issues!r}" + ) from e + if not isinstance(issues, list) or any(not isinstance(i, str) for i in issues): + raise ValueError("issues must be a list[str] or a JSON array string of strings") + data = CreateWorkItemRelation( relation_type=validated_relation_type, issues=issues, diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index b9cbfc5..dab3bc9 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -1,5 +1,6 @@ """Work item type-related tools for Plane MCP Server.""" +import json from typing import Any from fastmcp import FastMCP @@ -64,6 +65,19 @@ def create_work_item_type( """ client, workspace_slug = get_plane_client_context() + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(project_ids, str): + try: + project_ids = json.loads(project_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"project_ids must be a JSON array string or a list, got: {project_ids!r}" + ) from e + if project_ids is not None and ( + not isinstance(project_ids, list) or any(not isinstance(i, str) for i in project_ids) + ): + raise ValueError("project_ids must be a list[str] or a JSON array string of strings") + data = CreateWorkItemType( name=name, description=description, @@ -131,6 +145,19 @@ def update_work_item_type( """ client, workspace_slug = get_plane_client_context() + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(project_ids, str): + try: + project_ids = json.loads(project_ids) + except json.JSONDecodeError as e: + raise ValueError( + f"project_ids must be a JSON array string or a list, got: {project_ids!r}" + ) from e + if project_ids is not None and ( + not isinstance(project_ids, list) or any(not isinstance(i, str) for i in project_ids) + ): + raise ValueError("project_ids must be a list[str] or a JSON array string of strings") + data = UpdateWorkItemType( name=name, description=description, diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 49ceac1..62212a7 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -1,5 +1,6 @@ """Work item-related tools for Plane MCP Server.""" +import json from typing import get_args from fastmcp import FastMCP @@ -125,6 +126,30 @@ def create_work_item( priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment] ) + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(assignees, str): + try: + assignees = json.loads(assignees) + except json.JSONDecodeError as e: + raise ValueError( + f"assignees must be a JSON array string or a list, got: {assignees!r}" + ) from e + if isinstance(labels, str): + try: + labels = json.loads(labels) + except json.JSONDecodeError as e: + raise ValueError( + f"labels must be a JSON array string or a list, got: {labels!r}" + ) from e + if assignees is not None and ( + not isinstance(assignees, list) or any(not isinstance(i, str) for i in assignees) + ): + raise ValueError("assignees must be a list[str] or a JSON array string of strings") + if labels is not None and ( + not isinstance(labels, list) or any(not isinstance(i, str) for i in labels) + ): + raise ValueError("labels must be a list[str] or a JSON array string of strings") + data = CreateWorkItem( name=name, assignees=assignees, @@ -295,6 +320,30 @@ def update_work_item( priority if priority in get_args(PriorityEnum) else None # type: ignore[assignment] ) + # Some MCP clients serialize list parameters as JSON strings; handle both cases + if isinstance(assignees, str): + try: + assignees = json.loads(assignees) + except json.JSONDecodeError as e: + raise ValueError( + f"assignees must be a JSON array string or a list, got: {assignees!r}" + ) from e + if isinstance(labels, str): + try: + labels = json.loads(labels) + except json.JSONDecodeError as e: + raise ValueError( + f"labels must be a JSON array string or a list, got: {labels!r}" + ) from e + if assignees is not None and ( + not isinstance(assignees, list) or any(not isinstance(i, str) for i in assignees) + ): + raise ValueError("assignees must be a list[str] or a JSON array string of strings") + if labels is not None and ( + not isinstance(labels, list) or any(not isinstance(i, str) for i in labels) + ): + raise ValueError("labels must be a list[str] or a JSON array string of strings") + data = UpdateWorkItem( name=name, assignees=assignees, diff --git a/tests/test_integration.py b/tests/test_integration.py index 9ff5635..4ce77b4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -44,7 +44,7 @@ def extract_result(result): if hasattr(content, "text"): try: return json.loads(content.text) - except: + except (json.JSONDecodeError, TypeError): return {"raw": content.text} return {} diff --git a/tests/test_list_param_fix.py b/tests/test_list_param_fix.py new file mode 100644 index 0000000..933a23e --- /dev/null +++ b/tests/test_list_param_fix.py @@ -0,0 +1,461 @@ +""" +Unit tests for JSON-encoded string list parameter handling. + +Verifies that all tools accepting list parameters correctly handle the case +where values are passed as JSON-encoded strings (as some MCP clients serialize +list parameters this way) as well as the standard case where values are native +Python lists. +""" + +import json +from unittest.mock import MagicMock, patch + +from fastmcp import FastMCP + +from plane_mcp.tools.cycles import register_cycle_tools +from plane_mcp.tools.epics import register_epic_tools +from plane_mcp.tools.milestones import register_milestone_tools +from plane_mcp.tools.modules import register_module_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_types import register_work_item_type_tools +from plane_mcp.tools.work_items import register_work_item_tools + + +def make_mock_client(): + client = MagicMock() + client.modules.add_work_items = MagicMock(return_value=None) + client.cycles.add_work_items = MagicMock(return_value=None) + client.milestones.add_work_items = MagicMock(return_value=None) + client.milestones.remove_work_items = MagicMock(return_value=None) + return client + + +ISSUE_IDS = ["660bb007-c7b9-4f56-b9d7-7e468124083b", "7353ed39-a18b-4e91-a6f0-ae67c6ea4c05"] + + +@patch("plane_mcp.tools.modules.get_plane_client_context") +def test_add_work_items_to_module_with_json_string(mock_ctx): + """add_work_items_to_module should parse issue_ids when passed as a JSON-encoded string.""" + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_module_tools(mcp) + + # Retrieve the underlying function from the FastMCP tool registry + tool_fn = mcp._tool_manager._tools["add_work_items_to_module"].fn + + # Simulate an MCP client that serializes the list as a JSON string + tool_fn( + project_id="proj-1", + module_id="mod-1", + issue_ids=json.dumps(ISSUE_IDS), # passed as a JSON-encoded string + ) + + mock_client.modules.add_work_items.assert_called_once_with( + workspace_slug="test-workspace", + project_id="proj-1", + module_id="mod-1", + issue_ids=ISSUE_IDS, + ) + + +@patch("plane_mcp.tools.modules.get_plane_client_context") +def test_add_work_items_to_module_with_list(mock_ctx): + """add_work_items_to_module should work correctly when issue_ids is already a native list.""" + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_module_tools(mcp) + + tool_fn = mcp._tool_manager._tools["add_work_items_to_module"].fn + + tool_fn( + project_id="proj-1", + module_id="mod-1", + issue_ids=ISSUE_IDS, # passed as a native list + ) + + mock_client.modules.add_work_items.assert_called_once_with( + workspace_slug="test-workspace", + project_id="proj-1", + module_id="mod-1", + issue_ids=ISSUE_IDS, + ) + + +@patch("plane_mcp.tools.cycles.get_plane_client_context") +def test_add_work_items_to_cycle_with_json_string(mock_ctx): + """add_work_items_to_cycle should parse issue_ids when passed as a JSON-encoded string.""" + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_cycle_tools(mcp) + + tool_fn = mcp._tool_manager._tools["add_work_items_to_cycle"].fn + + tool_fn( + project_id="proj-1", + cycle_id="cycle-1", + issue_ids=json.dumps(ISSUE_IDS), + ) + + mock_client.cycles.add_work_items.assert_called_once_with( + workspace_slug="test-workspace", + project_id="proj-1", + cycle_id="cycle-1", + issue_ids=ISSUE_IDS, + ) + + +@patch("plane_mcp.tools.milestones.get_plane_client_context") +def test_add_work_items_to_milestone_with_json_string(mock_ctx): + """add_work_items_to_milestone should parse issue_ids when passed as a JSON-encoded string.""" + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_milestone_tools(mcp) + + tool_fn = mcp._tool_manager._tools["add_work_items_to_milestone"].fn + + tool_fn( + project_id="proj-1", + milestone_id="ms-1", + issue_ids=json.dumps(ISSUE_IDS), + ) + + mock_client.milestones.add_work_items.assert_called_once_with( + workspace_slug="test-workspace", + project_id="proj-1", + milestone_id="ms-1", + issue_ids=ISSUE_IDS, + ) + + +@patch("plane_mcp.tools.milestones.get_plane_client_context") +def test_remove_work_items_from_milestone_with_json_string(mock_ctx): + """remove_work_items_from_milestone should parse issue_ids when passed as a JSON-encoded string. + + Some MCP clients serialize list params as JSON strings; this verifies correct handling. + """ + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_milestone_tools(mcp) + + tool_fn = mcp._tool_manager._tools["remove_work_items_from_milestone"].fn + + tool_fn( + project_id="proj-1", + milestone_id="ms-1", + issue_ids=json.dumps(ISSUE_IDS), + ) + + mock_client.milestones.remove_work_items.assert_called_once_with( + workspace_slug="test-workspace", + project_id="proj-1", + milestone_id="ms-1", + issue_ids=ISSUE_IDS, + ) + + +# --- work_items.py tests --- + +@patch("plane_mcp.tools.work_items.get_plane_client_context") +def test_create_work_item_assignees_json_string(mock_ctx): + """create_work_item should parse assignees when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.create = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_work_item"].fn + tool_fn(project_id="proj-1", name="Test", assignees=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.create.call_args + assert call_kwargs.kwargs["data"].assignees == ISSUE_IDS + + +@patch("plane_mcp.tools.work_items.get_plane_client_context") +def test_create_work_item_labels_json_string(mock_ctx): + """create_work_item should parse labels when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.create = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_work_item"].fn + tool_fn(project_id="proj-1", name="Test", labels=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.create.call_args + assert call_kwargs.kwargs["data"].labels == ISSUE_IDS + + +@patch("plane_mcp.tools.work_items.get_plane_client_context") +def test_update_work_item_assignees_json_string(mock_ctx): + """update_work_item should parse assignees when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.update = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_work_item"].fn + tool_fn(project_id="proj-1", work_item_id="wi-1", assignees=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.update.call_args + assert call_kwargs.kwargs["data"].assignees == ISSUE_IDS + + +@patch("plane_mcp.tools.work_items.get_plane_client_context") +def test_update_work_item_labels_json_string(mock_ctx): + """update_work_item should parse labels when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.update = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_work_item"].fn + tool_fn(project_id="proj-1", work_item_id="wi-1", labels=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.update.call_args + assert call_kwargs.kwargs["data"].labels == ISSUE_IDS + + +# --- modules.py members tests --- + +@patch("plane_mcp.tools.modules.get_plane_client_context") +def test_create_module_members_json_string(mock_ctx): + """create_module should parse members when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.modules.create = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_module_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_module"].fn + tool_fn(project_id="proj-1", name="Module 1", members=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.modules.create.call_args + assert call_kwargs.kwargs["data"].members == ISSUE_IDS + + +@patch("plane_mcp.tools.modules.get_plane_client_context") +def test_update_module_members_json_string(mock_ctx): + """update_module should parse members when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.modules.update = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_module_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_module"].fn + tool_fn(project_id="proj-1", module_id="mod-1", members=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.modules.update.call_args + assert call_kwargs.kwargs["data"].members == ISSUE_IDS + + +# --- work_item_relations.py tests --- + +@patch("plane_mcp.tools.work_item_relations.get_plane_client_context") +def test_create_work_item_relation_issues_json_string(mock_ctx): + """create_work_item_relation should parse issues when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.relations.create = MagicMock(return_value=None) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_relation_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_work_item_relation"].fn + tool_fn( + project_id="proj-1", + work_item_id="wi-1", + relation_type="blocking", + issues=json.dumps(ISSUE_IDS), + ) + + call_kwargs = mock_client.work_items.relations.create.call_args + assert call_kwargs.kwargs["data"].issues == ISSUE_IDS + + +# --- epics.py tests --- + +@patch("plane_mcp.tools.epics.get_plane_client_context") +def test_create_epic_assignees_json_string(mock_ctx): + """create_epic should parse assignees when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_epic_type = MagicMock() + mock_epic_type.id = "epic-type-uuid" + mock_epic_type.is_epic = True + mock_client.work_item_types.list.return_value = [mock_epic_type] + mock_client.work_items.create.return_value = MagicMock(id="epic-work-item-id") + mock_client.epics.retrieve.return_value = MagicMock() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_epic_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_epic"].fn + tool_fn(project_id="proj-1", name="Epic 1", assignees=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.create.call_args + assert call_kwargs.kwargs["data"].assignees == ISSUE_IDS + + +@patch("plane_mcp.tools.epics.get_plane_client_context") +def test_update_epic_assignees_json_string(mock_ctx): + """update_epic should parse assignees when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.update.return_value = MagicMock(id="epic-work-item-id") + mock_client.epics.retrieve.return_value = MagicMock() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_epic_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_epic"].fn + tool_fn(project_id="proj-1", epic_id="epic-1", assignees=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.update.call_args + assert call_kwargs.kwargs["data"].assignees == ISSUE_IDS + + +# --- work_item_properties.py tests --- + +@patch("plane_mcp.tools.work_item_properties.get_plane_client_context") +def test_create_work_item_property_default_value_json_string(mock_ctx): + """create_work_item_property should parse default_value when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_item_properties.create = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_property_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_work_item_property"].fn + tool_fn( + project_id="proj-1", + type_id="type-1", + display_name="My Property", + property_type="DECIMAL", + default_value=json.dumps(ISSUE_IDS), + ) + + call_kwargs = mock_client.work_item_properties.create.call_args + assert call_kwargs.kwargs["data"].default_value == ISSUE_IDS + + +@patch("plane_mcp.tools.work_item_properties.get_plane_client_context") +def test_update_work_item_property_default_value_json_string(mock_ctx): + """update_work_item_property should parse default_value when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_item_properties.update = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_property_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_work_item_property"].fn + tool_fn( + project_id="proj-1", + type_id="type-1", + work_item_property_id="prop-1", + default_value=json.dumps(ISSUE_IDS), + ) + + call_kwargs = mock_client.work_item_properties.update.call_args + assert call_kwargs.kwargs["data"].default_value == ISSUE_IDS + + +# --- work_item_types.py tests --- + +@patch("plane_mcp.tools.work_item_types.get_plane_client_context") +def test_create_work_item_type_project_ids_json_string(mock_ctx): + """create_work_item_type should parse project_ids when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_item_types.create = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_type_tools(mcp) + + tool_fn = mcp._tool_manager._tools["create_work_item_type"].fn + tool_fn(project_id="proj-1", name="Bug", project_ids=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_item_types.create.call_args + assert call_kwargs.kwargs["data"].project_ids == ISSUE_IDS + + +@patch("plane_mcp.tools.work_item_types.get_plane_client_context") +def test_update_work_item_type_project_ids_json_string(mock_ctx): + """update_work_item_type should parse project_ids when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_item_types.update = MagicMock(return_value=MagicMock()) + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_work_item_type_tools(mcp) + + tool_fn = mcp._tool_manager._tools["update_work_item_type"].fn + tool_fn( + project_id="proj-1", + work_item_type_id="wt-1", + project_ids=json.dumps(ISSUE_IDS), + ) + + call_kwargs = mock_client.work_item_types.update.call_args + assert call_kwargs.kwargs["data"].project_ids == ISSUE_IDS + + +@patch("plane_mcp.tools.epics.get_plane_client_context") +def test_create_epic_labels_json_string(mock_ctx): + """create_epic should parse labels when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_epic_type = MagicMock() + mock_epic_type.id = "epic-type-uuid" + mock_epic_type.is_epic = True + mock_client.work_item_types.list.return_value = [mock_epic_type] + mock_client.work_items.create.return_value = MagicMock(id="epic-work-item-id") + mock_client.epics.retrieve.return_value = MagicMock() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_epic_tools(mcp) + tool_fn = mcp._tool_manager._tools["create_epic"].fn + tool_fn(project_id="proj-1", name="Epic 1", labels=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.create.call_args + assert call_kwargs.kwargs["data"].labels == ISSUE_IDS + + +@patch("plane_mcp.tools.epics.get_plane_client_context") +def test_update_epic_labels_json_string(mock_ctx): + """update_epic should parse labels when passed as a JSON-encoded string.""" + mock_client = MagicMock() + mock_client.work_items.update.return_value = MagicMock(id="epic-work-item-id") + mock_client.epics.retrieve.return_value = MagicMock() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_epic_tools(mcp) + tool_fn = mcp._tool_manager._tools["update_epic"].fn + tool_fn(project_id="proj-1", epic_id="epic-1", labels=json.dumps(ISSUE_IDS)) + + call_kwargs = mock_client.work_items.update.call_args + assert call_kwargs.kwargs["data"].labels == ISSUE_IDS diff --git a/tests/test_oauth_security.py b/tests/test_oauth_security.py index 50f11b8..f0fc670 100644 --- a/tests/test_oauth_security.py +++ b/tests/test_oauth_security.py @@ -22,7 +22,6 @@ from plane_mcp.auth import PlaneOAuthProvider - # Exact allowed patterns from plane_mcp/server.py ALLOWED_REDIRECT_URI_PATTERNS = [ "http://localhost:*",