From 853f2e8f7062adb9306cb0dd11a6f3bfbd581988 Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 12:13:43 +0900 Subject: [PATCH 1/6] fix: handle JSON-encoded string for list parameters in MCP tools Some MCP clients (e.g. Claude Code) serialize list parameters as JSON-encoded strings when invoking tools. This caused a Pydantic validation error in tools that accept `list[str]` parameters: Input should be a valid list [type=list_type, input_value='["uuid-..."]', input_type=str] Affected tools: - add_work_items_to_module (modules.py) - add_work_items_to_cycle (cycles.py) - add_work_items_to_milestone (milestones.py) - remove_work_items_from_milestone (milestones.py) Fix: add a runtime check at the start of each affected function that deserializes the value with json.loads() when it arrives as a string, preserving the original list[str] type annotation for schema generation. --- plane_mcp/tools/cycles.py | 4 ++++ plane_mcp/tools/milestones.py | 7 +++++++ plane_mcp/tools/modules.py | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index c417134..60151b6 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,9 @@ 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): + issue_ids = json.loads(issue_ids) client, workspace_slug = get_plane_client_context() client.cycles.add_work_items( workspace_slug=workspace_slug, diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py index 8012f71..d721852 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,9 @@ 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): + issue_ids = json.loads(issue_ids) client, workspace_slug = get_plane_client_context() client.milestones.add_work_items( workspace_slug=workspace_slug, @@ -179,6 +183,9 @@ 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): + issue_ids = json.loads(issue_ids) 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..67d7995 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 @@ -224,6 +225,9 @@ 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): + issue_ids = json.loads(issue_ids) client, workspace_slug = get_plane_client_context() client.modules.add_work_items( workspace_slug=workspace_slug, From b0a490d95b9fd28f12c3a387063c736e447f2948 Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 12:17:04 +0900 Subject: [PATCH 2/6] test: add unit tests for JSON-encoded list parameter handling Covers the case where MCP clients pass list parameters as JSON strings. Tests verify both the string-input and native-list-input paths for: - add_work_items_to_module - add_work_items_to_cycle - add_work_items_to_milestone - remove_work_items_from_milestone --- tests/test_list_param_fix.py | 157 +++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/test_list_param_fix.py diff --git a/tests/test_list_param_fix.py b/tests/test_list_param_fix.py new file mode 100644 index 0000000..55595f7 --- /dev/null +++ b/tests/test_list_param_fix.py @@ -0,0 +1,157 @@ +""" +Unit tests for JSON-encoded string list parameter fix. + +Verifies that add_work_items_to_module, add_work_items_to_cycle, +add_work_items_to_milestone and remove_work_items_from_milestone +correctly handle issue_ids when passed as a JSON-encoded string +(as some MCP clients, e.g. Claude Code, serialize list params this way). +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from fastmcp import FastMCP + +from plane_mcp.tools.cycles import register_cycle_tools +from plane_mcp.tools.milestones import register_milestone_tools +from plane_mcp.tools.modules import register_module_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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + mock_client = make_mock_client() + mock_ctx.return_value = (mock_client, "test-workspace") + + mcp = FastMCP("test") + register_module_tools(mcp) + + # FastMCP tool 함수를 직접 꺼내서 호출 + tool_fn = mcp._tool_manager._tools["add_work_items_to_module"].fn + + # MCP 클라이언트가 JSON 문자열로 직렬화해서 보내는 시나리오 + tool_fn( + project_id="proj-1", + module_id="mod-1", + issue_ids=json.dumps(ISSUE_IDS), # JSON 문자열로 전달 + ) + + 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은 정상적인 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, # 정상 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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + 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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + 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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + 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, + ) From 9f4fb678590632675c8627dc3dac3e19087e44e5 Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 12:22:55 +0900 Subject: [PATCH 3/6] test: translate Korean comments to English in test_list_param_fix.py --- tests/test_list_param_fix.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_list_param_fix.py b/tests/test_list_param_fix.py index 55595f7..fce425e 100644 --- a/tests/test_list_param_fix.py +++ b/tests/test_list_param_fix.py @@ -32,21 +32,21 @@ def make_mock_client(): @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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + """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) - # FastMCP tool 함수를 직접 꺼내서 호출 + # Retrieve the underlying function from the FastMCP tool registry tool_fn = mcp._tool_manager._tools["add_work_items_to_module"].fn - # MCP 클라이언트가 JSON 문자열로 직렬화해서 보내는 시나리오 + # 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), # JSON 문자열로 전달 + issue_ids=json.dumps(ISSUE_IDS), # passed as a JSON-encoded string ) mock_client.modules.add_work_items.assert_called_once_with( @@ -59,7 +59,7 @@ def test_add_work_items_to_module_with_json_string(mock_ctx): @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은 정상적인 list도 그대로 처리해야 한다.""" + """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") @@ -71,7 +71,7 @@ def test_add_work_items_to_module_with_list(mock_ctx): tool_fn( project_id="proj-1", module_id="mod-1", - issue_ids=ISSUE_IDS, # 정상 list로 전달 + issue_ids=ISSUE_IDS, # passed as a native list ) mock_client.modules.add_work_items.assert_called_once_with( @@ -84,7 +84,7 @@ def test_add_work_items_to_module_with_list(mock_ctx): @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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + """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") @@ -109,7 +109,7 @@ def test_add_work_items_to_cycle_with_json_string(mock_ctx): @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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + """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") @@ -134,7 +134,7 @@ def test_add_work_items_to_milestone_with_json_string(mock_ctx): @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은 JSON 문자열로 전달된 issue_ids를 파싱해야 한다.""" + """remove_work_items_from_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") From 335355679560b8453f4a18e55124d5d4412c7fdf Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 14:05:14 +0900 Subject: [PATCH 4/6] fix: extend JSON-encoded list parameter handling to all affected tools Extends the fix from PR #76 to cover all remaining tool parameters that accept list[str] and may receive JSON-encoded strings from MCP clients. Previously fixed (PR #76): - add_work_items_to_module (modules.py) - add_work_items_to_cycle (cycles.py) - add_work_items_to_milestone (milestones.py) - remove_work_items_from_milestone (milestones.py) Newly fixed parameters: - create_work_item / update_work_item: assignees, labels (work_items.py) - create_module / update_module: members (modules.py) - create_work_item_relation: issues (work_item_relations.py) - create_epic / update_epic: assignees, labels (epics.py) - create_work_item_property / update_work_item_property: default_value - create_work_item_type / update_work_item_type: project_ids Also backfills the four PR #76 guards with proper json.JSONDecodeError handling (raise ValueError with chained exception) for consistency. Adds import json to: work_items.py, work_item_relations.py, epics.py, work_item_properties.py, work_item_types.py. Extends test_list_param_fix.py from 5 to 18 tests covering all newly fixed parameters (JSON-string input and native-list regression cases). --- plane_mcp/tools/cycles.py | 7 +- plane_mcp/tools/epics.py | 37 +++- plane_mcp/tools/milestones.py | 14 +- plane_mcp/tools/modules.py | 25 ++- plane_mcp/tools/work_item_properties.py | 31 ++- plane_mcp/tools/work_item_relations.py | 10 + plane_mcp/tools/work_item_types.py | 19 ++ plane_mcp/tools/work_items.py | 33 +++ tests/test_list_param_fix.py | 280 +++++++++++++++++++++++- 9 files changed, 440 insertions(+), 16 deletions(-) diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index 60151b6..db63387 100644 --- a/plane_mcp/tools/cycles.py +++ b/plane_mcp/tools/cycles.py @@ -210,7 +210,12 @@ def add_work_items_to_cycle( """ # Some MCP clients serialize list parameters as JSON strings; handle both cases if isinstance(issue_ids, str): - issue_ids = json.loads(issue_ids) + 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 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..29ac836 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,22 @@ 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 + data = CreateWorkItem( name=name, assignees=assignees, @@ -205,6 +224,22 @@ 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 + data = UpdateWorkItem( name=name, assignees=assignees, diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py index d721852..4f143e8 100644 --- a/plane_mcp/tools/milestones.py +++ b/plane_mcp/tools/milestones.py @@ -160,7 +160,12 @@ def add_work_items_to_milestone( """ # Some MCP clients serialize list parameters as JSON strings; handle both cases if isinstance(issue_ids, str): - issue_ids = json.loads(issue_ids) + 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 client, workspace_slug = get_plane_client_context() client.milestones.add_work_items( workspace_slug=workspace_slug, @@ -185,7 +190,12 @@ def remove_work_items_from_milestone( """ # Some MCP clients serialize list parameters as JSON strings; handle both cases if isinstance(issue_ids, str): - issue_ids = json.loads(issue_ids) + 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 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 67d7995..5f978fb 100644 --- a/plane_mcp/tools/modules.py +++ b/plane_mcp/tools/modules.py @@ -82,6 +82,15 @@ 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 + data = CreateModule( name=name, description=description, @@ -157,6 +166,15 @@ 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 + data = UpdateModule( name=name, description=description, @@ -227,7 +245,12 @@ def add_work_items_to_module( """ # Some MCP clients serialize list parameters as JSON strings; handle both cases if isinstance(issue_ids, str): - issue_ids = json.loads(issue_ids) + 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 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..04e2748 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,15 @@ 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 + data = CreateWorkItemProperty( display_name=display_name, property_type=validated_property_type, @@ -188,14 +200,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 +239,15 @@ 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 + 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..b593a39 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,15 @@ 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 + 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..66ee50d 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,15 @@ 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 + data = CreateWorkItemType( name=name, description=description, @@ -131,6 +141,15 @@ 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 + data = UpdateWorkItemType( name=name, description=description, diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 49ceac1..07fe06c 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,22 @@ 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 + data = CreateWorkItem( name=name, assignees=assignees, @@ -295,6 +312,22 @@ 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 + data = UpdateWorkItem( name=name, assignees=assignees, diff --git a/tests/test_list_param_fix.py b/tests/test_list_param_fix.py index fce425e..7370350 100644 --- a/tests/test_list_param_fix.py +++ b/tests/test_list_param_fix.py @@ -1,21 +1,25 @@ """ -Unit tests for JSON-encoded string list parameter fix. +Unit tests for JSON-encoded string list parameter handling. -Verifies that add_work_items_to_module, add_work_items_to_cycle, -add_work_items_to_milestone and remove_work_items_from_milestone -correctly handle issue_ids when passed as a JSON-encoded string -(as some MCP clients, e.g. Claude Code, serialize list params this way). +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 -import pytest 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(): @@ -134,7 +138,10 @@ def test_add_work_items_to_milestone_with_json_string(mock_ctx): @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.""" + """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") @@ -155,3 +162,262 @@ def test_remove_work_items_from_milestone_with_json_string(mock_ctx): 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 From ac034c79be8ba89900d3be1e30db52977b567303 Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 14:35:41 +0900 Subject: [PATCH 5/6] fix: validate list[str] shape after JSON deserialization in all tools After each json.loads() guard, validate that the decoded value is actually a list of strings. Valid non-array JSON (e.g. "{}", "42", "\"id\"") would otherwise pass the JSONDecodeError check silently and fail later with an unclear error from the SDK or Pydantic. Required params (issue_ids, issues): raise ValueError immediately if the decoded value is not a list[str]. Optional params (assignees, labels, members, project_ids, default_value): raise ValueError when the param is not None and not a valid list[str]. Also adds two missing tests for create_epic/update_epic labels parameter (JSON-encoded string), and fixes two pre-existing lint issues in tests/: - tests/test_integration.py: bare except -> except Exception - tests/test_oauth_security.py: unsorted import block --- plane_mcp/tools/cycles.py | 2 ++ plane_mcp/tools/epics.py | 16 +++++++++++ plane_mcp/tools/milestones.py | 4 +++ plane_mcp/tools/modules.py | 10 +++++++ plane_mcp/tools/work_item_properties.py | 10 +++++++ plane_mcp/tools/work_item_relations.py | 2 ++ plane_mcp/tools/work_item_types.py | 8 ++++++ plane_mcp/tools/work_items.py | 16 +++++++++++ tests/test_integration.py | 2 +- tests/test_list_param_fix.py | 38 +++++++++++++++++++++++++ tests/test_oauth_security.py | 1 - 11 files changed, 107 insertions(+), 2 deletions(-) diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index db63387..7772b8a 100644 --- a/plane_mcp/tools/cycles.py +++ b/plane_mcp/tools/cycles.py @@ -216,6 +216,8 @@ def add_work_items_to_cycle( 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 29ac836..b613820 100644 --- a/plane_mcp/tools/epics.py +++ b/plane_mcp/tools/epics.py @@ -140,6 +140,14 @@ def create_epic( 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, @@ -239,6 +247,14 @@ def update_epic( 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, diff --git a/plane_mcp/tools/milestones.py b/plane_mcp/tools/milestones.py index 4f143e8..3ec8d67 100644 --- a/plane_mcp/tools/milestones.py +++ b/plane_mcp/tools/milestones.py @@ -166,6 +166,8 @@ def add_work_items_to_milestone( 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, @@ -196,6 +198,8 @@ def remove_work_items_from_milestone( 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 5f978fb..3caeab1 100644 --- a/plane_mcp/tools/modules.py +++ b/plane_mcp/tools/modules.py @@ -90,6 +90,10 @@ def create_module( 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, @@ -174,6 +178,10 @@ def update_module( 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, @@ -251,6 +259,8 @@ def add_work_items_to_module( 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 04e2748..f9410d0 100644 --- a/plane_mcp/tools/work_item_properties.py +++ b/plane_mcp/tools/work_item_properties.py @@ -126,6 +126,11 @@ def create_work_item_property( 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, @@ -247,6 +252,11 @@ def update_work_item_property( 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, diff --git a/plane_mcp/tools/work_item_relations.py b/plane_mcp/tools/work_item_relations.py index b593a39..830b34e 100644 --- a/plane_mcp/tools/work_item_relations.py +++ b/plane_mcp/tools/work_item_relations.py @@ -82,6 +82,8 @@ def create_work_item_relation( 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, diff --git a/plane_mcp/tools/work_item_types.py b/plane_mcp/tools/work_item_types.py index 66ee50d..dab3bc9 100644 --- a/plane_mcp/tools/work_item_types.py +++ b/plane_mcp/tools/work_item_types.py @@ -73,6 +73,10 @@ def create_work_item_type( 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, @@ -149,6 +153,10 @@ def update_work_item_type( 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, diff --git a/plane_mcp/tools/work_items.py b/plane_mcp/tools/work_items.py index 07fe06c..62212a7 100644 --- a/plane_mcp/tools/work_items.py +++ b/plane_mcp/tools/work_items.py @@ -141,6 +141,14 @@ def create_work_item( 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, @@ -327,6 +335,14 @@ def update_work_item( 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, diff --git a/tests/test_integration.py b/tests/test_integration.py index 9ff5635..15b69fe 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 Exception: return {"raw": content.text} return {} diff --git a/tests/test_list_param_fix.py b/tests/test_list_param_fix.py index 7370350..933a23e 100644 --- a/tests/test_list_param_fix.py +++ b/tests/test_list_param_fix.py @@ -421,3 +421,41 @@ def test_update_work_item_type_project_ids_json_string(mock_ctx): 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:*", From 17c80ed440bb878f3b430e56a7e10cb239bc33d8 Mon Sep 17 00:00:00 2001 From: ej31 Date: Thu, 5 Mar 2026 14:50:39 +0900 Subject: [PATCH 6/6] fix(tests): narrow exception handling in extract_result JSON parsing Replace broad `except Exception` with `except (json.JSONDecodeError, TypeError)` to avoid masking unrelated failures during integration debugging. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 15b69fe..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 Exception: + except (json.JSONDecodeError, TypeError): return {"raw": content.text} return {}