diff --git a/plane_mcp/lite_fallback.py b/plane_mcp/lite_fallback.py new file mode 100644 index 0000000..2c58738 --- /dev/null +++ b/plane_mcp/lite_fallback.py @@ -0,0 +1,60 @@ +"""Fall back to a full list endpoint when a Plane server doesn't expose the matching "-lite" route. + +Self-hosted Plane Community Edition instances don't register the ``-lite`` +endpoints added for Cloud performance (``projects-lite``, ``cycles-lite``, +``modules-lite``, ``members-lite``) -- calling them returns HTTP 404 even +though the regular endpoint and the underlying data are fine. When that +happens, retry against the full endpoint and reshape its response into the +lite envelope the tool advertises. + +See: https://github.com/makeplane/plane-mcp-server/issues/126, /163, /169, +/170, /172. +""" + +from collections.abc import Callable +from typing import Any, TypeVar + +from plane.errors.errors import HttpError +from pydantic import BaseModel + +TLite = TypeVar("TLite", bound=BaseModel) + + +def lite_or_fallback( + lite_call: Callable[[], TLite], + full_call: Callable[[], Any], + lite_item_cls: type[BaseModel], + lite_response_cls: type[TLite], +) -> TLite: + """Call `lite_call`; on HTTP 404 retry via `full_call` and reshape into the lite envelope. + + `full_call` may return either a paginated envelope (an object with a + `results` list) or a bare list of items -- both shapes are normalized + into `lite_response_cls`. + """ + try: + return lite_call() + except HttpError as exc: + if exc.status_code != 404: + raise + + full = full_call() + items = full if isinstance(full, list) else full.results + results = [lite_item_cls.model_validate(item.model_dump()) for item in items] + + if isinstance(full, list): + return lite_response_cls.model_validate( + { + "results": results, + "total_count": len(results), + "next_cursor": "", + "prev_cursor": "", + "next_page_results": False, + "prev_page_results": False, + "count": len(results), + "total_pages": 1, + "total_results": len(results), + } + ) + + return lite_response_cls.model_validate({**full.model_dump(), "results": results}) diff --git a/plane_mcp/tools/cycles.py b/plane_mcp/tools/cycles.py index b2dc9b0..5848c83 100644 --- a/plane_mcp/tools/cycles.py +++ b/plane_mcp/tools/cycles.py @@ -9,6 +9,7 @@ from plane.models.cycles import ( CreateCycle, Cycle, + CycleLite, PaginatedArchivedCycleResponse, PaginatedCycleLiteResponse, PaginatedCycleWorkItemResponse, @@ -16,10 +17,16 @@ UpdateCycle, ) from plane.models.enums import CycleStatusEnum -from plane.models.query_params import CycleLiteListQueryParams, LiteListQueryParams, WorkItemQueryParams +from plane.models.query_params import ( + CycleListQueryParams, + CycleLiteListQueryParams, + LiteListQueryParams, + WorkItemQueryParams, +) from pydantic import Field from plane_mcp.client import get_plane_client_context +from plane_mcp.lite_fallback import lite_or_fallback from plane_mcp.tools.pql_reference import PQL_FIELD_HINT, PQL_FULL_REFERENCE logger = get_logger(__name__) @@ -64,7 +71,16 @@ def list_cycles( params=params.model_dump(exclude_none=True), ) params = CycleLiteListQueryParams(cursor=cursor, per_page=per_page, order_by=order_by, status=status) - return client.cycles.list_lite(workspace_slug=workspace_slug, project_id=project_id, params=params) + return lite_or_fallback( + lambda: client.cycles.list_lite(workspace_slug=workspace_slug, project_id=project_id, params=params), + lambda: client.cycles.list( + workspace_slug=workspace_slug, + project_id=project_id, + params=CycleListQueryParams(cursor=cursor, per_page=per_page, order_by=order_by, status=status), + ), + CycleLite, + PaginatedCycleLiteResponse, + ) @mcp.tool() def create_cycle( diff --git a/plane_mcp/tools/modules.py b/plane_mcp/tools/modules.py index 7c8d008..6bc49f8 100644 --- a/plane_mcp/tools/modules.py +++ b/plane_mcp/tools/modules.py @@ -9,15 +9,17 @@ from plane.models.modules import ( CreateModule, Module, + ModuleLite, PaginatedArchivedModuleResponse, PaginatedModuleLiteResponse, PaginatedModuleWorkItemResponse, UpdateModule, ) -from plane.models.query_params import LiteListQueryParams, WorkItemQueryParams +from plane.models.query_params import LiteListQueryParams, PaginatedQueryParams, WorkItemQueryParams from pydantic import Field from plane_mcp.client import get_plane_client_context +from plane_mcp.lite_fallback import lite_or_fallback from plane_mcp.tools.pql_reference import PQL_FIELD_HINT, PQL_FULL_REFERENCE logger = get_logger(__name__) @@ -57,8 +59,17 @@ def list_modules( project_id=project_id, params=params.model_dump(exclude_none=True), ) - return client.modules.list_lite( - workspace_slug=workspace_slug, project_id=project_id, params=params + return lite_or_fallback( + lambda: client.modules.list_lite(workspace_slug=workspace_slug, project_id=project_id, params=params), + lambda: client.modules.list( + workspace_slug=workspace_slug, + project_id=project_id, + params=PaginatedQueryParams(cursor=cursor, per_page=per_page, order_by=order_by).model_dump( + exclude_none=True + ), + ), + ModuleLite, + PaginatedModuleLiteResponse, ) @mcp.tool() diff --git a/plane_mcp/tools/projects.py b/plane_mcp/tools/projects.py index 37c6b96..625116d 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -16,15 +16,17 @@ CreateProject, PaginatedProjectLiteResponse, PaginatedProjectMemberResponse, + PaginatedProjectResponse, Project, ProjectFeature, + ProjectLite, ProjectWorklogSummary, UpdateProject, ) -from plane.models.query_params import ProjectLiteListQueryParams -from plane.models.query_params import MemberListQueryParams +from plane.models.query_params import MemberListQueryParams, PaginatedQueryParams, ProjectLiteListQueryParams from plane_mcp.client import get_plane_client_context +from plane_mcp.lite_fallback import lite_or_fallback def register_project_tools(mcp: FastMCP) -> None: @@ -53,12 +55,25 @@ def list_projects( """ client, workspace_slug = get_plane_client_context() - params = ProjectLiteListQueryParams( - cursor=cursor, per_page=per_page, order_by=order_by, include_archived=False + params = ProjectLiteListQueryParams(cursor=cursor, per_page=per_page, order_by=order_by, include_archived=False) + + def _list_full() -> PaginatedProjectResponse: + # PaginatedQueryParams has no include_archived toggle (unlike the lite + # path's ProjectLiteListQueryParams), so filter archived projects out + # client-side to match the lite path's include_archived=False default. + response = client.projects.list( + workspace_slug=workspace_slug, + params=PaginatedQueryParams(cursor=cursor, per_page=per_page, order_by=order_by), + ) + return response.model_copy(update={"results": [p for p in response.results if p.archived_at is None]}) + + return lite_or_fallback( + lambda: client.projects.list_lite(workspace_slug=workspace_slug, params=params), + _list_full, + ProjectLite, + PaginatedProjectLiteResponse, ) - return client.projects.list_lite(workspace_slug=workspace_slug, params=params) - @mcp.tool() def create_project( name: str, @@ -341,9 +356,7 @@ def get_project_members( per_page=per_page, order_by=order_by, ) - return client.projects.get_members_lite( - workspace_slug=workspace_slug, project_id=project_id, params=params - ) + return client.projects.get_members_lite(workspace_slug=workspace_slug, project_id=project_id, params=params) @mcp.tool() def update_project_features( diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index ec9992b..4815226 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -2,10 +2,11 @@ from fastmcp import FastMCP from plane.models.projects import ProjectFeature -from plane.models.query_params import MemberListQueryParams -from plane.models.workspaces import PaginatedWorkspaceMemberResponse, WorkspaceFeature +from plane.models.query_params import MemberListQueryParams, MemberQueryParams +from plane.models.workspaces import PaginatedWorkspaceMemberResponse, WorkspaceFeature, WorkspaceMember from plane_mcp.client import get_plane_client_context +from plane_mcp.lite_fallback import lite_or_fallback def register_workspace_tools(mcp: FastMCP) -> None: @@ -52,7 +53,28 @@ def get_workspace_members( per_page=per_page, order_by=order_by, ) - return client.workspaces.get_members_lite(workspace_slug=workspace_slug, params=params) + # get_members (the fallback for self-hosted CE instances lacking members-lite) + # is documented as unpaginated -- MemberQueryParams has no cursor/per_page + # fields because the underlying endpoint always returns every member in one + # bare list, so the requested cursor/per_page can't be forwarded. + return lite_or_fallback( + lambda: client.workspaces.get_members_lite(workspace_slug=workspace_slug, params=params), + lambda: client.workspaces.get_members( + workspace_slug=workspace_slug, + params=MemberQueryParams( + first_name=first_name, + last_name=last_name, + email=email, + display_name=display_name, + role_slug=role_slug, + is_active=is_active, + is_bot=is_bot, + order_by=order_by, + ), + ), + WorkspaceMember, + PaginatedWorkspaceMemberResponse, + ) @mcp.tool() def get_features(project_id: str | None = None) -> WorkspaceFeature | ProjectFeature: diff --git a/tests/test_lite_fallback.py b/tests/test_lite_fallback.py new file mode 100644 index 0000000..51a066b --- /dev/null +++ b/tests/test_lite_fallback.py @@ -0,0 +1,142 @@ +"""Unit tests for plane_mcp.lite_fallback.""" + +import pytest +from plane.errors.errors import HttpError +from pydantic import BaseModel + +from plane_mcp.lite_fallback import lite_or_fallback + + +class _LiteItem(BaseModel): + id: str + name: str + + +class _LiteResponse(BaseModel): + results: list[_LiteItem] + total_count: int + next_cursor: str + prev_cursor: str + next_page_results: bool + prev_page_results: bool + count: int + total_pages: int + total_results: int + + +class _FullItem(BaseModel): + id: str + name: str + extra_field: str + + +class _FullResponse(BaseModel): + results: list[_FullItem] + total_count: int + next_cursor: str + prev_cursor: str + next_page_results: bool + prev_page_results: bool + count: int + total_pages: int + total_results: int + + +def test_returns_lite_result_when_lite_call_succeeds(): + lite_response = _LiteResponse( + results=[_LiteItem(id="1", name="a")], + total_count=1, + next_cursor="", + prev_cursor="", + next_page_results=False, + prev_page_results=False, + count=1, + total_pages=1, + total_results=1, + ) + full_call_invoked = False + + def full_call(): + nonlocal full_call_invoked + full_call_invoked = True + raise AssertionError("full_call should not be invoked when lite_call succeeds") + + result = lite_or_fallback(lambda: lite_response, full_call, _LiteItem, _LiteResponse) + + assert result is lite_response + assert not full_call_invoked + + +def test_falls_back_to_paginated_full_response_on_404(): + full_response = _FullResponse( + results=[_FullItem(id="1", name="a", extra_field="x")], + total_count=1, + next_cursor="100:0:0", + prev_cursor="", + next_page_results=False, + prev_page_results=False, + count=1, + total_pages=1, + total_results=1, + ) + + def lite_call(): + raise HttpError("Not Found", status_code=404, response={"error": "Page not found."}) + + result = lite_or_fallback(lite_call, lambda: full_response, _LiteItem, _LiteResponse) + + assert isinstance(result, _LiteResponse) + assert result.results == [_LiteItem(id="1", name="a")] + assert result.next_cursor == "100:0:0" + assert result.total_count == 1 + + +def test_falls_back_to_bare_list_full_response_on_404(): + full_items = [_FullItem(id="1", name="a", extra_field="x"), _FullItem(id="2", name="b", extra_field="y")] + + def lite_call(): + raise HttpError("Not Found", status_code=404, response={"error": "Page not found."}) + + result = lite_or_fallback(lite_call, lambda: full_items, _LiteItem, _LiteResponse) + + assert isinstance(result, _LiteResponse) + assert result.results == [_LiteItem(id="1", name="a"), _LiteItem(id="2", name="b")] + assert result.total_count == 2 + assert result.next_page_results is False + + +def test_reraises_non_404_http_errors(): + def lite_call(): + raise HttpError("Server Error", status_code=500, response={"error": "boom"}) + + def full_call(): + raise AssertionError("full_call should not be invoked on non-404 errors") + + with pytest.raises(HttpError) as exc_info: + lite_or_fallback(lite_call, full_call, _LiteItem, _LiteResponse) + + assert exc_info.value.status_code == 500 + + +def test_propagates_error_from_full_call_after_404(): + def lite_call(): + raise HttpError("Not Found", status_code=404, response={"error": "Page not found."}) + + def full_call(): + raise HttpError("Server Error", status_code=500, response={"error": "boom"}) + + with pytest.raises(HttpError) as exc_info: + lite_or_fallback(lite_call, full_call, _LiteItem, _LiteResponse) + + assert exc_info.value.status_code == 500 + + +def test_falls_back_to_empty_bare_list_on_404(): + def lite_call(): + raise HttpError("Not Found", status_code=404, response={"error": "Page not found."}) + + result = lite_or_fallback(lite_call, lambda: [], _LiteItem, _LiteResponse) + + assert isinstance(result, _LiteResponse) + assert result.results == [] + assert result.total_count == 0