Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions plane_mcp/tools/modules.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Module-related tools for Plane MCP Server."""

from typing import Any, get_args
from typing import Any, TypedDict, get_args
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check plane-sdk for the add_work_items return type and related Pydantic models

# Search for the add_work_items method definition
rg -n -A 10 'def add_work_items' --type py

# Search for Pydantic models related to module-issue associations
rg -n 'class.*Module.*Issue|class.*Issue.*Module' --type py

# Check imports and type hints in the modules client
ast-grep --pattern 'def add_work_items($$$) -> $RET_TYPE:
  $$$'

Repository: makeplane/plane-mcp-server

Length of output: 3515


🏁 Script executed:

# Read the modules.py file to see the TypedDict definition and implementation
cat -n plane_mcp/tools/modules.py | sed -n '1,40p'

Repository: makeplane/plane-mcp-server

Length of output: 1430


🏁 Script executed:

# Read the return statement area
cat -n plane_mcp/tools/modules.py | sed -n '213,240p'

Repository: makeplane/plane-mcp-server

Length of output: 1086


🏁 Script executed:

# Search for TypedDict definition in the file
rg -n 'class AddWorkItemsToModuleResult' -A 5 plane_mcp/tools/modules.py

Repository: makeplane/plane-mcp-server

Length of output: 230


🏁 Script executed:

# Check imports at the top of modules.py to see what's available from plane-sdk
cat -n plane_mcp/tools/modules.py | head -30

Repository: makeplane/plane-mcp-server

Length of output: 1036


🏁 Script executed:

# Search for plane-sdk Pydantic models related to modules or issues
find . -path ./node_modules -prune -o -type f -name "*.py" -exec grep -l "class.*Module.*Pydantic\|class.*BaseModel" {} \; | head -20

Repository: makeplane/plane-mcp-server

Length of output: 111


🏁 Script executed:

# Look for what client.modules.add_work_items actually returns by checking the call site
rg -n 'client\.modules\.add_work_items' -B 2 -A 5 plane_mcp/tools/modules.py

Repository: makeplane/plane-mcp-server

Length of output: 371


🏁 Script executed:

# Check if PaginatedModuleWorkItemResponse or similar could be used
rg -n 'PaginatedModuleWorkItemResponse' -B 2 -A 10 --type py | head -50

Repository: makeplane/plane-mcp-server

Length of output: 1643


🏁 Script executed:

# Search in plane-sdk imports to see what models are available for module operations
python3 << 'EOF'
import sys
# Check what pydantic models are imported from plane.models.modules
print("Models imported from plane.models.modules:")
models = ["CreateModule", "Module", "PaginatedArchivedModuleResponse", 
          "PaginatedModuleResponse", "PaginatedModuleWorkItemResponse", "UpdateModule"]
for m in models:
    print(f"  - {m}")
EOF

Repository: makeplane/plane-mcp-server

Length of output: 258


🏁 Script executed:

# Check if there's a ModuleWorkItem or similar model for the returned items
rg -n 'class.*Module.*Item|class.*ModuleIssue|ModuleWorkItem' --type py | grep -v test | head -20

Repository: makeplane/plane-mcp-server

Length of output: 252


🏁 Script executed:

# Verify what the actual plane-sdk client.modules.add_work_items method signature is
# by looking at any type hints or documentation in the codebase
rg -n 'modules.add_work_items' -B 3 -A 8 --type py

Repository: makeplane/plane-mcp-server

Length of output: 1439


🏁 Script executed:

# Check other tools in the codebase to see how they handle similar situations
rg -n 'def add_work_items_to' -A 15 plane_mcp/tools/*.py

Repository: makeplane/plane-mcp-server

Length of output: 3074


Replace custom TypedDict return type with a plane-sdk Pydantic model or None.

The coding guideline requires "Tool functions must return Pydantic models from plane-sdk", but AddWorkItemsToModuleResult is a locally-defined TypedDict (line 20). The underlying client.modules.add_work_items() call does not return data—it's a side-effect operation. Either return None (consistent with similar methods in cycles.py and milestones.py), or if structured output is needed, use an appropriate Pydantic model from the plane.models package rather than a custom TypedDict.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane_mcp/tools/modules.py` at line 3, The local TypedDict
AddWorkItemsToModuleResult must be removed and the function
add_work_items_to_module should return either None (preferred, matching
cycles.py and milestones.py) or a plane-sdk Pydantic model from plane.models;
update the function that calls client.modules.add_work_items() (which is a
side-effect operation) to return None and adjust its type hints and any callers
accordingly, or if you need structured output, replace the TypedDict with an
appropriate plane.models Pydantic class and return an instance of that model
instead of the custom TypedDict.


from fastmcp import FastMCP
from plane.models.enums import ModuleStatusEnum
Expand All @@ -17,6 +17,12 @@
from plane_mcp.client import get_plane_client_context


class AddWorkItemsToModuleResult(TypedDict):
"""Result returned after successfully adding work items to a module."""

success: bool


def register_module_tools(mcp: FastMCP) -> None:
"""Register all module-related tools with the MCP server."""

Expand Down Expand Up @@ -208,14 +214,17 @@ def add_work_items_to_module(
project_id: str,
module_id: str,
work_item_ids: list[str],
) -> None:
) -> AddWorkItemsToModuleResult:
"""
Add work items to a module.

Args:
project_id: UUID of the project
module_id: UUID of the module
work_item_ids: List of work item UUIDs to add to the module

Returns:
Success status for the operation
"""
client, workspace_slug = get_plane_client_context()
client.modules.add_work_items(
Expand All @@ -224,6 +233,7 @@ def add_work_items_to_module(
module_id=module_id,
issue_ids=work_item_ids,
)
return {"success": True}

@mcp.tool()
def remove_work_item_from_module(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def extract_result(result):
if hasattr(content, "text"):
try:
return json.loads(content.text)
except:
except json.JSONDecodeError:
return {"raw": content.text}
return {}

Expand Down
69 changes: 69 additions & 0 deletions tests/test_modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for module tools."""

import asyncio

from fastmcp import Client, FastMCP

from plane_mcp.tools import modules as module_tools


class FakeModulesClient:
def __init__(self) -> None:
self.add_work_items_calls: list[dict[str, object]] = []

def add_work_items(
self,
workspace_slug: str,
project_id: str,
module_id: str,
issue_ids: list[str],
) -> list[dict[str, str]]:
self.add_work_items_calls.append(
{
"workspace_slug": workspace_slug,
"project_id": project_id,
"module_id": module_id,
"issue_ids": issue_ids,
}
)
return [{"id": "module-issue-id", "module": module_id, "issue": issue_ids[0]}]


class FakePlaneClient:
def __init__(self) -> None:
self.modules = FakeModulesClient()


def test_add_work_items_to_module_returns_success_payload(monkeypatch) -> None:
"""A successful SDK list response should not leak into MCP response validation."""
fake_client = FakePlaneClient()
monkeypatch.setattr(
module_tools,
"get_plane_client_context",
lambda: (fake_client, "test-workspace"),
)

async def call_tool() -> object:
mcp = FastMCP("test")
module_tools.register_module_tools(mcp)

async with Client(mcp) as client:
result = await client.call_tool(
"add_work_items_to_module",
{
"project_id": "project-id",
"module_id": "module-id",
"work_item_ids": ["work-item-id"],
},
)
return result.structured_content

assert asyncio.run(call_tool()) == {"success": True}
assert fake_client.modules.add_work_items_calls == [
{
"workspace_slug": "test-workspace",
"project_id": "project-id",
"module_id": "module-id",
"issue_ids": ["work-item-id"],
}
]
1 change: 0 additions & 1 deletion tests/test_oauth_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@

from plane_mcp.auth import PlaneOAuthProvider


# Exact allowed patterns from plane_mcp/server.py
ALLOWED_REDIRECT_URI_PATTERNS = [
"http://localhost:*",
Expand Down
1 change: 0 additions & 1 deletion tests/test_stateless_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

from plane_mcp.auth import PlaneHeaderAuthProvider, PlaneOAuthProvider


ALLOWED_REDIRECT_URI_PATTERNS = [
"http://localhost:*",
"http://localhost:*/*",
Expand Down