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
60 changes: 60 additions & 0 deletions plane_mcp/lite_fallback.py
Original file line number Diff line number Diff line change
@@ -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})
20 changes: 18 additions & 2 deletions plane_mcp/tools/cycles.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@
from plane.models.cycles import (
CreateCycle,
Cycle,
CycleLite,
PaginatedArchivedCycleResponse,
PaginatedCycleLiteResponse,
PaginatedCycleWorkItemResponse,
TransferCycleWorkItemsRequest,
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__)
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 14 additions & 3 deletions plane_mcp/tools/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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()
Expand Down
31 changes: 22 additions & 9 deletions plane_mcp/tools/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
28 changes: 25 additions & 3 deletions plane_mcp/tools/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
142 changes: 142 additions & 0 deletions tests/test_lite_fallback.py
Original file line number Diff line number Diff line change
@@ -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