From 12a9bb7fb2a82bcf21f8326f8f95b468c1789e34 Mon Sep 17 00:00:00 2001 From: Steve Brodson Date: Sat, 11 Jul 2026 09:07:53 -0500 Subject: [PATCH 1/2] fix: fall back to full list endpoints when -lite routes 404 on self-hosted CE list_projects, list_cycles, list_modules, and get_workspace_members call the *-lite endpoints added in 0.2.10 (projects-lite, cycles-lite, modules-lite, members-lite) for Cloud performance. Self-hosted Plane Community Edition instances don't register these routes, so all four tools 404 even though the workspace, auth, and the non-lite endpoints work fine. Add a lite_or_fallback() helper that retries via the full endpoint on a 404 and reshapes its response into the lite envelope the tool advertises, then wire it into the four affected tools. Fixes #172, #170, #169, #126 Co-Authored-By: Claude Sonnet 5 --- plane_mcp/lite_fallback.py | 58 +++++++++++++++++ plane_mcp/tools/cycles.py | 20 +++++- plane_mcp/tools/modules.py | 17 ++++- plane_mcp/tools/projects.py | 23 ++++--- plane_mcp/tools/workspaces.py | 24 ++++++- tests/test_lite_fallback.py | 118 ++++++++++++++++++++++++++++++++++ 6 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 plane_mcp/lite_fallback.py create mode 100644 tests/test_lite_fallback.py diff --git a/plane_mcp/lite_fallback.py b/plane_mcp/lite_fallback.py new file mode 100644 index 0000000..29750c1 --- /dev/null +++ b/plane_mcp/lite_fallback.py @@ -0,0 +1,58 @@ +"""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( + 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..0b55e0a 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -18,13 +18,14 @@ PaginatedProjectMemberResponse, 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,11 +54,17 @@ 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) - return client.projects.list_lite(workspace_slug=workspace_slug, params=params) + return lite_or_fallback( + lambda: client.projects.list_lite(workspace_slug=workspace_slug, params=params), + lambda: client.projects.list( + workspace_slug=workspace_slug, + params=PaginatedQueryParams(cursor=cursor, per_page=per_page, order_by=order_by), + ), + ProjectLite, + PaginatedProjectLiteResponse, + ) @mcp.tool() def create_project( @@ -341,9 +348,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..d5b9dc4 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,24 @@ def get_workspace_members( per_page=per_page, order_by=order_by, ) - return client.workspaces.get_members_lite(workspace_slug=workspace_slug, params=params) + 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..9d4e5a5 --- /dev/null +++ b/tests/test_lite_fallback.py @@ -0,0 +1,118 @@ +"""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 From b5f63898d3aaabf780cc7a2cab62c8c6e0346811 Mon Sep 17 00:00:00 2001 From: Steve Brodson Date: Sat, 11 Jul 2026 09:36:05 -0500 Subject: [PATCH 2/2] fix: address CodeRabbit review on lite-endpoint fallback PR - projects.py: filter archived projects client-side in the fallback path, since the full /projects/ endpoint's PaginatedQueryParams has no include_archived toggle, matching the lite path's default. - workspaces.py: document why the get_members fallback can't forward cursor/per_page (the endpoint is unpaginated by design). - lite_fallback.py: use model_validate for the bare-list branch, consistent with the paginated branch. - tests: cover full_call raising after a 404 and an empty bare-list fallback. Co-Authored-By: Claude Sonnet 5 --- plane_mcp/lite_fallback.py | 22 ++++++++++++---------- plane_mcp/tools/projects.py | 16 ++++++++++++---- plane_mcp/tools/workspaces.py | 4 ++++ tests/test_lite_fallback.py | 24 ++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/plane_mcp/lite_fallback.py b/plane_mcp/lite_fallback.py index 29750c1..2c58738 100644 --- a/plane_mcp/lite_fallback.py +++ b/plane_mcp/lite_fallback.py @@ -43,16 +43,18 @@ def lite_or_fallback( results = [lite_item_cls.model_validate(item.model_dump()) for item in items] if isinstance(full, list): - return lite_response_cls( - 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( + { + "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/projects.py b/plane_mcp/tools/projects.py index 0b55e0a..625116d 100644 --- a/plane_mcp/tools/projects.py +++ b/plane_mcp/tools/projects.py @@ -16,6 +16,7 @@ CreateProject, PaginatedProjectLiteResponse, PaginatedProjectMemberResponse, + PaginatedProjectResponse, Project, ProjectFeature, ProjectLite, @@ -56,12 +57,19 @@ def list_projects( params = ProjectLiteListQueryParams(cursor=cursor, per_page=per_page, order_by=order_by, include_archived=False) - return lite_or_fallback( - lambda: client.projects.list_lite(workspace_slug=workspace_slug, params=params), - lambda: client.projects.list( + 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, ) diff --git a/plane_mcp/tools/workspaces.py b/plane_mcp/tools/workspaces.py index d5b9dc4..4815226 100644 --- a/plane_mcp/tools/workspaces.py +++ b/plane_mcp/tools/workspaces.py @@ -53,6 +53,10 @@ def get_workspace_members( per_page=per_page, order_by=order_by, ) + # 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( diff --git a/tests/test_lite_fallback.py b/tests/test_lite_fallback.py index 9d4e5a5..51a066b 100644 --- a/tests/test_lite_fallback.py +++ b/tests/test_lite_fallback.py @@ -116,3 +116,27 @@ def full_call(): 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