From f788a5d46c0e6e7f4afbee83d237566295c664bf Mon Sep 17 00:00:00 2001 From: davgordo Date: Thu, 16 Jul 2026 13:03:36 -0500 Subject: [PATCH] feat: add project membership management tools --- README.md | 3 + plane_mcp/tools/projects.py | 159 +++++++++++++- tests/test_integration.py | 3 + tests/test_project_memberships.py | 347 ++++++++++++++++++++++++++++++ 4 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 tests/test_project_memberships.py diff --git a/README.md b/README.md index 191fc42..3af2288 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,9 @@ The server provides comprehensive tools for interacting with Plane. All tools us | `delete_project` | Delete a project by ID | | `get_project_worklog_summary` | Get work log summary for a project | | `get_project_members` | Get all members of a project | +| `add_project_members` | Add existing workspace users to a project | +| `update_project_member` | Update a project member's role | +| `remove_project_member` | Remove a member from a project | | `update_project_features` | Update features configuration of a project | ### Work Items diff --git a/plane_mcp/tools/projects.py b/plane_mcp/tools/projects.py index 37c6b96..cda1758 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -1,6 +1,6 @@ """Project-related tools for Plane MCP Server.""" -from typing import get_args +from typing import Any, Literal, get_args from fastmcp import FastMCP from plane.models.enums import TimezoneEnum @@ -21,11 +21,67 @@ ProjectWorklogSummary, UpdateProject, ) -from plane.models.query_params import ProjectLiteListQueryParams -from plane.models.query_params import MemberListQueryParams +from plane.models.query_params import MemberListQueryParams, ProjectLiteListQueryParams +from pydantic import BaseModel, ConfigDict, Field from plane_mcp.client import get_plane_client_context +ProjectRole = Literal[5, 15, 20] +VALID_PROJECT_ROLES = {5, 15, 20} + + +class ProjectMemberInput(BaseModel): + """Input for adding an existing workspace user to a project.""" + + member: str = Field(..., description="Workspace user UUID to add to the project.") + role: ProjectRole = Field(..., description="Project role: 5=Guest, 15=Member, 20=Admin.") + + +class ProjectMemberMutationResponse(BaseModel): + """Project membership response returned by Plane's member mutation endpoints.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = Field(None, description="Project membership record UUID.") + member: str | dict[str, Any] | None = Field(None, description="Workspace user UUID or user object.") + role: int | None = None + is_active: bool | None = None + + +class ProjectMembershipAPI: + """Small bridge for project member mutations not exposed by plane-sdk.""" + + def __init__(self, projects_resource: Any) -> None: + self._projects = projects_resource + + def create(self, workspace_slug: str, project_id: str, member: str, role: int) -> ProjectMemberMutationResponse: + response = self._projects._post( + f"{workspace_slug}/projects/{project_id}/project-members", + {"member": member, "role": role}, + ) + return ProjectMemberMutationResponse.model_validate(response) + + def update_role( + self, + workspace_slug: str, + project_id: str, + project_member_id: str, + role: int, + ) -> ProjectMemberMutationResponse: + response = self._projects._patch( + f"{workspace_slug}/projects/{project_id}/project-members/{project_member_id}", + {"role": role}, + ) + return ProjectMemberMutationResponse.model_validate(response) + + def delete(self, workspace_slug: str, project_id: str, project_member_id: str) -> None: + self._projects._delete(f"{workspace_slug}/projects/{project_id}/project-members/{project_member_id}") + + +def _validate_project_role(role: int) -> None: + if role not in VALID_PROJECT_ROLES: + raise ValueError("role must be one of 5 (Guest), 15 (Member), or 20 (Admin)") + def register_project_tools(mcp: FastMCP) -> None: """Register all project-related tools with the MCP server.""" @@ -327,6 +383,9 @@ def get_project_members( Returns: Paginated envelope: results (members incl. role, role_slug, is_active, is_bot) + total_count, next_cursor, next_page_results. + Plane's public project-member listing returns user records; each + returned `id` is the workspace-user UUID, not the project-membership + record UUID used by update_project_member and remove_project_member. """ client, workspace_slug = get_plane_client_context() params = MemberListQueryParams( @@ -345,6 +404,100 @@ def get_project_members( workspace_slug=workspace_slug, project_id=project_id, params=params ) + @mcp.tool() + def add_project_members( + project_id: str, + members: list[ProjectMemberInput], + ) -> list[ProjectMemberMutationResponse]: + """ + Add existing workspace users to a project. + + Args: + project_id: UUID of the project + members: One or more workspace users and roles to add. Each + `member` is a workspace user UUID. Role must be 5 (Guest), + 15 (Member), or 20 (Admin). + + Returns: + Project membership records returned by Plane. Each record's `id` + is the project_member_id used by update_project_member and + remove_project_member for that newly added membership. + """ + if not members: + raise ValueError("members must contain at least one project member to add") + + member_ids = [member.member for member in members] + duplicate_member_ids = {member_id for member_id in member_ids if member_ids.count(member_id) > 1} + if duplicate_member_ids: + duplicate_list = ", ".join(sorted(duplicate_member_ids)) + raise ValueError(f"duplicate member values are not allowed: {duplicate_list}") + + for member in members: + _validate_project_role(member.role) + + client, workspace_slug = get_plane_client_context() + memberships_api = ProjectMembershipAPI(client.projects) + return [ + memberships_api.create( + workspace_slug=workspace_slug, + project_id=project_id, + member=member.member, + role=member.role, + ) + for member in members + ] + + @mcp.tool() + def update_project_member( + project_id: str, + project_member_id: str, + role: ProjectRole, + ) -> ProjectMemberMutationResponse: + """ + Change a project membership's role. + + Args: + project_id: UUID of the project + project_member_id: Project membership record UUID, such as the `id` + returned by add_project_members. + role: New project role: 5 (Guest), 15 (Member), or 20 (Admin) + + Returns: + Updated project membership record returned by Plane. + """ + _validate_project_role(role) + + client, workspace_slug = get_plane_client_context() + return ProjectMembershipAPI(client.projects).update_role( + workspace_slug=workspace_slug, + project_id=project_id, + project_member_id=project_member_id, + role=role, + ) + + @mcp.tool() + def remove_project_member(project_id: str, project_member_id: str) -> None: + """ + Remove a project membership. + + Plane removes or deactivates the project membership and returns HTTP + 204. This does not remove the user from the workspace. + + Args: + project_id: UUID of the project + project_member_id: Project membership record UUID, such as the `id` + returned by add_project_members. + + Returns: + None + """ + client, workspace_slug = get_plane_client_context() + ProjectMembershipAPI(client.projects).delete( + workspace_slug=workspace_slug, + project_id=project_id, + project_member_id=project_member_id, + ) + @mcp.tool() def update_project_features( project_id: str, diff --git a/tests/test_integration.py b/tests/test_integration.py index f894f68..df4a897 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -267,6 +267,9 @@ def test_full_integration(): "retrieve_project", "update_project", "delete_project", + "add_project_members", + "update_project_member", + "remove_project_member", # Work item tools "create_work_item", "list_work_items", diff --git a/tests/test_project_memberships.py b/tests/test_project_memberships.py new file mode 100644 index 0000000..207497c --- /dev/null +++ b/tests/test_project_memberships.py @@ -0,0 +1,347 @@ +import asyncio + +import pytest +from fastmcp import FastMCP +from plane.errors.errors import HttpError +from plane.models.projects import PaginatedProjectMemberResponse, ProjectMember + +from plane_mcp.tools import projects as project_tools +from plane_mcp.tools.projects import ProjectMemberInput, register_project_tools + + +def _tool_fn(name): + async def get_fn(): + mcp = FastMCP("test") + register_project_tools(mcp) + tool = await mcp.get_tool(name) + return tool.fn + + return asyncio.run(get_fn()) + + +def _paginated_members(*members): + return PaginatedProjectMemberResponse.model_validate( + { + "results": list(members), + "total_count": len(members), + "count": len(members), + "next_cursor": "next", + "prev_cursor": "", + "next_page_results": False, + "prev_page_results": False, + "total_pages": 1, + "total_results": len(members), + } + ) + + +class FakeProjects: + def __init__(self): + self.calls = [] + self.member_response = _paginated_members( + ProjectMember.model_validate( + { + "id": "workspace-user-1", + "email": "member@example.com", + "display_name": "Member One", + "first_name": "Member", + "last_name": "One", + "avatar": "", + "avatar_url": "", + } + ) + ) + self.post_responses = [ + {"id": "project-member-1", "member": "workspace-user-1", "role": 15, "is_active": True}, + {"id": "project-member-2", "member": "workspace-user-2", "role": 20, "is_active": True}, + ] + self.patch_response = {"id": "project-member-1", "member": "workspace-user-1", "role": 20, "is_active": True} + + def get_members_lite(self, **kwargs): + self.calls.append(("get_members_lite", kwargs)) + return self.member_response + + def _post(self, endpoint, data): + self.calls.append(("_post", endpoint, data)) + return self.post_responses.pop(0) + + def _patch(self, endpoint, data): + self.calls.append(("_patch", endpoint, data)) + return self.patch_response + + def _delete(self, endpoint): + self.calls.append(("_delete", endpoint)) + return None + + +@pytest.fixture +def fake_projects(monkeypatch): + projects = FakeProjects() + client = type("Client", (), {"projects": projects})() + monkeypatch.setattr(project_tools, "get_plane_client_context", lambda: (client, "workspace")) + return projects + + +def test_get_project_members_preserves_current_paginated_filters(fake_projects): + fn = _tool_fn("get_project_members") + + response = fn( + project_id="project", + first_name="Ana", + last_name="Ng", + email="ana@example.com", + display_name="Ana N", + role_slug="member", + is_active=True, + is_bot=False, + cursor="cursor", + per_page=50, + order_by="-created_at", + ) + + assert response.results[0].id == "workspace-user-1" + assert "member" not in response.results[0].model_dump(exclude_none=True) + method, kwargs = fake_projects.calls[0] + assert method == "get_members_lite" + assert kwargs["workspace_slug"] == "workspace" + assert kwargs["project_id"] == "project" + query = kwargs["params"].to_query_params() + assert query == { + "first_name": "Ana", + "last_name": "Ng", + "email": "ana@example.com", + "display_name": "Ana N", + "role_slug": "member", + "is_active": "true", + "is_bot": "false", + "cursor": "cursor", + "per_page": 50, + "order_by": "-created_at", + } + + +def test_get_project_members_id_is_workspace_user_not_mutation_id(fake_projects): + list_fn = _tool_fn("get_project_members") + + listed_member = list_fn(project_id="project").results[0] + + assert listed_member.id == "workspace-user-1" + assert fake_projects.calls == [ + ( + "get_members_lite", + { + "workspace_slug": "workspace", + "project_id": "project", + "params": fake_projects.calls[0][1]["params"], + }, + ) + ] + + +def test_project_member_id_returned_by_add_is_usable_for_update(fake_projects): + add_fn = _tool_fn("add_project_members") + update_fn = _tool_fn("update_project_member") + + added = add_fn(project_id="project", members=[ProjectMemberInput(member="workspace-user-1", role=15)])[0] + result = update_fn(project_id="project", project_member_id=added.id, role=20) + + assert added.id == "project-member-1" + assert added.member == "workspace-user-1" + assert result.id == "project-member-1" + assert result.member == "workspace-user-1" + assert result.role == 20 + assert fake_projects.calls[-1] == ( + "_patch", + "workspace/projects/project/project-members/project-member-1", + {"role": 20}, + ) + + +def test_add_project_members_single(fake_projects): + fn = _tool_fn("add_project_members") + + result = fn(project_id="project", members=[ProjectMemberInput(member="workspace-user-1", role=15)]) + + assert result[0].id == "project-member-1" + assert result[0].member == "workspace-user-1" + assert ( + "_post", + "workspace/projects/project/project-members", + {"member": "workspace-user-1", "role": 15}, + ) in fake_projects.calls + + +def test_add_project_members_bulk(fake_projects): + fn = _tool_fn("add_project_members") + + result = fn( + project_id="project", + members=[ + ProjectMemberInput(member="workspace-user-1", role=15), + ProjectMemberInput(member="workspace-user-2", role=20), + ], + ) + + assert [member.id for member in result] == ["project-member-1", "project-member-2"] + assert ( + "_post", + "workspace/projects/project/project-members", + {"member": "workspace-user-1", "role": 15}, + ) in fake_projects.calls + assert ( + "_post", + "workspace/projects/project/project-members", + {"member": "workspace-user-2", "role": 20}, + ) in fake_projects.calls + + +def test_add_project_members_rejects_empty_input_before_api_call(fake_projects): + fn = _tool_fn("add_project_members") + + with pytest.raises(ValueError, match="at least one"): + fn(project_id="project", members=[]) + + assert fake_projects.calls == [] + + +def test_add_project_members_rejects_duplicate_workspace_users_before_api_call(fake_projects): + fn = _tool_fn("add_project_members") + + with pytest.raises(ValueError, match="duplicate member"): + fn( + project_id="project", + members=[ + ProjectMemberInput(member="workspace-user-1", role=15), + ProjectMemberInput(member="workspace-user-1", role=20), + ], + ) + + assert fake_projects.calls == [] + + +@pytest.mark.parametrize("role", [5, 15, 20]) +def test_project_member_roles_accept_current_plane_values(fake_projects, role): + fn = _tool_fn("add_project_members") + + result = fn(project_id="project", members=[ProjectMemberInput(member="workspace-user-1", role=role)]) + + assert result[0].id == "project-member-1" + + +@pytest.mark.parametrize("tool_name", ["add_project_members", "update_project_member"]) +def test_project_member_tools_reject_invalid_roles_before_api_call(fake_projects, tool_name): + fn = _tool_fn(tool_name) + + with pytest.raises(ValueError, match="role must be one of"): + if tool_name == "add_project_members": + fn( + project_id="project", + members=[ProjectMemberInput.model_construct(member="workspace-user-1", role=99)], + ) + else: + fn(project_id="project", project_member_id="project-member-1", role=99) + + assert fake_projects.calls == [] + + +def test_add_project_members_preserves_api_validation_errors(fake_projects): + fn = _tool_fn("add_project_members") + + def fail_validation(endpoint, data): + raise HttpError("HTTP 400: Bad Request", 400, {"member": ["Member not found in workspace"]}) + + fake_projects._post = fail_validation + + with pytest.raises(HttpError) as error: + fn(project_id="project", members=[ProjectMemberInput(member="missing-user", role=15)]) + + assert error.value.status_code == 400 + assert error.value.response == {"member": ["Member not found in workspace"]} + + +def test_add_project_members_preserves_active_duplicate_errors(fake_projects): + fn = _tool_fn("add_project_members") + + def fail_duplicate(endpoint, data): + raise HttpError("HTTP 400: Bad Request", 400, {"error": "The fields member, project must make a unique set."}) + + fake_projects._post = fail_duplicate + + with pytest.raises(HttpError) as error: + fn(project_id="project", members=[ProjectMemberInput(member="workspace-user-1", role=15)]) + + assert error.value.status_code == 400 + assert error.value.response == {"error": "The fields member, project must make a unique set."} + + +def test_add_project_members_preserves_inactive_reactivation_errors(fake_projects): + fn = _tool_fn("add_project_members") + + def fail_inactive_duplicate(endpoint, data): + raise HttpError("HTTP 400: Bad Request", 400, {"error": "The fields member, project must make a unique set."}) + + fake_projects._post = fail_inactive_duplicate + + with pytest.raises(HttpError) as error: + fn(project_id="project", members=[ProjectMemberInput(member="workspace-user-1", role=15)]) + + assert error.value.status_code == 400 + assert error.value.response == {"error": "The fields member, project must make a unique set."} + + +def test_update_project_member_preserves_api_permission_errors(fake_projects): + fn = _tool_fn("update_project_member") + + def fail_permission(endpoint, data): + raise HttpError("HTTP 403: Forbidden", 403, {"detail": "Forbidden"}) + + fake_projects._patch = fail_permission + + with pytest.raises(HttpError) as error: + fn(project_id="project", project_member_id="project-member-1", role=20) + + assert error.value.status_code == 403 + assert error.value.response == {"detail": "Forbidden"} + + +def test_remove_project_member_uses_project_member_id_and_returns_none(fake_projects): + fn = _tool_fn("remove_project_member") + + result = fn(project_id="project", project_member_id="project-member-1") + + assert result is None + assert fake_projects.calls == [("_delete", "workspace/projects/project/project-members/project-member-1")] + + +def test_remove_project_member_preserves_soft_deactivation_permission_errors(fake_projects): + fn = _tool_fn("remove_project_member") + + def fail_permission(endpoint): + raise HttpError("HTTP 403: Forbidden", 403, {"detail": "Forbidden"}) + + fake_projects._delete = fail_permission + + with pytest.raises(HttpError) as error: + fn(project_id="project", project_member_id="project-member-1") + + assert error.value.status_code == 403 + assert error.value.response == {"detail": "Forbidden"} + + +def test_fastmcp_registration_and_schemas(): + async def inspect_tools(): + mcp = FastMCP("test") + register_project_tools(mcp) + add_tool = await mcp.get_tool("add_project_members") + update_tool = await mcp.get_tool("update_project_member") + remove_tool = await mcp.get_tool("remove_project_member") + return add_tool, update_tool, remove_tool + + add_tool, update_tool, remove_tool = asyncio.run(inspect_tools()) + + assert add_tool.parameters["properties"]["members"]["items"]["$ref"] == "#/$defs/ProjectMemberInput" + assert add_tool.parameters["$defs"]["ProjectMemberInput"]["properties"]["role"]["enum"] == [5, 15, 20] + assert "ProjectMemberMutationResponse" in add_tool.output_schema["$defs"] + assert update_tool.parameters["properties"]["project_member_id"]["type"] == "string" + assert update_tool.output_schema["properties"]["id"]["description"] == "Project membership record UUID." + assert remove_tool.output_schema is None