fix(deck): address PR #759 round-2 review feedback

- Move description_max_length validation to tool layer
  (_validate_description_max_length), matching the existing
  _validate_comment_message pattern; helper now trusts callers per
  CLAUDE.md ("validate at system boundaries only").
- Fix mutation/return inconsistency: deck_get_stacks now uses a list
  comprehension to capture _apply_stack_filters' return, matching
  deck_get_stack / deck_get_archived_stacks.
- Rename include_archived -> include_archived_cards on deck_get_cards
  and _apply_card_filters for consistency with deck_get_stacks.
- Route deck_get_archived_stacks through _apply_stack_filters so
  future filters apply uniformly to active + archived paths.
- Trim _truncate_card_descriptions docstring to one line; add inline
  comment in _apply_stack_filters explaining the breaking-change
  default (mirrors Deck UI archived-card filtering).
- Replace fragile call_args[0][1] with call_args.args[1] in the
  archived-stacks client test.
- Modernize Optional[X] -> X | None throughout deck.py (adjacent
  cleanup called out in the review).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-03 00:50:56 +02:00
co-authored by Claude Opus 4.7
parent 7d633a945d
commit b7805c2180
3 changed files with 86 additions and 64 deletions
+55 -48
View File
@@ -1,5 +1,4 @@
import logging import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
@@ -31,32 +30,21 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _truncate_card_descriptions( def _validate_description_max_length(description_max_length: int | None) -> None:
cards: list[DeckCard], description_max_length: int | None """Tool-layer guard: reject zero/negative truncation thresholds."""
) -> None: if description_max_length is not None and description_max_length <= 0:
"""Truncate each card's description in-place when it strictly exceeds the
limit.
Descriptions whose length is less than or equal to ``description_max_length``
are left untouched (no ellipsis is appended). Descriptions longer than the
limit are truncated to ``description_max_length`` characters and an
ellipsis ("") is appended, so the resulting string is
``description_max_length + 1`` characters total.
Args:
cards: Cards to mutate in place.
description_max_length: Positive truncation threshold, or ``None`` to
skip truncation entirely.
Raises:
ValueError: If ``description_max_length`` is not positive.
"""
if description_max_length is None:
return
if description_max_length <= 0:
raise ValueError( raise ValueError(
f"description_max_length must be positive, got {description_max_length}" f"description_max_length must be positive, got {description_max_length}"
) )
def _truncate_card_descriptions(
cards: list[DeckCard], description_max_length: int | None
) -> None:
"""Truncate descriptions strictly longer than the limit; appends "" so
the truncated result is ``description_max_length + 1`` chars."""
if description_max_length is None:
return
for card in cards: for card in cards:
if card.description and len(card.description) > description_max_length: if card.description and len(card.description) > description_max_length:
card.description = card.description[:description_max_length] + "" card.description = card.description[:description_max_length] + ""
@@ -87,6 +75,10 @@ def _apply_stack_filters(
description_max_length: int | None, description_max_length: int | None,
) -> DeckStack: ) -> DeckStack:
"""Apply card-shaping filters to a single stack (in-place).""" """Apply card-shaping filters to a single stack (in-place)."""
# Note: the upstream Deck API returns archived cards inline within
# active stacks (the Deck UI filters them frontend-side). Defaulting
# include_archived_cards to False mirrors that UI behavior — this is
# the breaking change called out in the PR description.
if not include_cards: if not include_cards:
stack.cards = None stack.cards = None
elif stack.cards: elif stack.cards:
@@ -99,11 +91,11 @@ def _apply_stack_filters(
def _apply_card_filters( def _apply_card_filters(
cards: list[DeckCard], cards: list[DeckCard],
*, *,
include_archived: bool, include_archived_cards: bool,
description_max_length: int | None, description_max_length: int | None,
) -> list[DeckCard]: ) -> list[DeckCard]:
"""Apply filters to a flat list of cards. Returns a (possibly new) list.""" """Apply filters to a flat list of cards. Returns a (possibly new) list."""
if not include_archived: if not include_archived_cards:
cards = [c for c in cards if not c.archived] cards = [c for c in cards if not c.archived]
_truncate_card_descriptions(cards, description_max_length) _truncate_card_descriptions(cards, description_max_length)
return cards return cards
@@ -275,15 +267,18 @@ def configure_deck_tools(mcp: FastMCP):
to this many characters. Useful for keeping responses compact to this many characters. Useful for keeping responses compact
on boards with long card specs. on boards with long card specs.
""" """
_validate_description_max_length(description_max_length)
client = await get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id) stacks = await client.deck.get_stacks(board_id)
for stack in stacks: stacks = [
_apply_stack_filters( _apply_stack_filters(
stack, stack,
include_cards=include_cards, include_cards=include_cards,
include_archived_cards=include_archived_cards, include_archived_cards=include_archived_cards,
description_max_length=description_max_length, description_max_length=description_max_length,
) )
for stack in stacks
]
return ListStacksResponse(stacks=stacks, total=len(stacks)) return ListStacksResponse(stacks=stacks, total=len(stacks))
@mcp.tool( @mcp.tool(
@@ -311,6 +306,7 @@ def configure_deck_tools(mcp: FastMCP):
description_max_length: If set, truncate each card's description description_max_length: If set, truncate each card's description
to this many characters. to this many characters.
""" """
_validate_description_max_length(description_max_length)
client = await get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
return _apply_stack_filters( return _apply_stack_filters(
@@ -343,11 +339,21 @@ def configure_deck_tools(mcp: FastMCP):
description_max_length: If set, truncate each card's description description_max_length: If set, truncate each card's description
to this many characters. to this many characters.
""" """
_validate_description_max_length(description_max_length)
client = await get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_archived_stacks(board_id) stacks = await client.deck.get_archived_stacks(board_id)
for stack in stacks: # All cards in archived stacks are themselves archived; route through
if stack.cards: # the same helper as the active-stack path so future filter additions
_truncate_card_descriptions(stack.cards, description_max_length) # apply uniformly.
stacks = [
_apply_stack_filters(
stack,
include_cards=True,
include_archived_cards=True,
description_max_length=description_max_length,
)
for stack in stacks
]
return ListStacksResponse(stacks=stacks, total=len(stacks)) return ListStacksResponse(stacks=stacks, total=len(stacks))
@mcp.tool( @mcp.tool(
@@ -360,7 +366,7 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
board_id: int, board_id: int,
stack_id: int, stack_id: int,
include_archived: bool = False, include_archived_cards: bool = False,
description_max_length: int | None = None, description_max_length: int | None = None,
) -> ListCardsResponse: ) -> ListCardsResponse:
"""Get all cards in a Nextcloud Deck stack. """Get all cards in a Nextcloud Deck stack.
@@ -368,17 +374,18 @@ def configure_deck_tools(mcp: FastMCP):
Args: Args:
board_id: The ID of the board board_id: The ID of the board
stack_id: The ID of the stack stack_id: The ID of the stack
include_archived: Include archived cards (default False). Archived include_archived_cards: Include archived cards (default False).
cards can also be retrieved per-board via Archived cards can also be retrieved per-board via
deck_get_archived_stacks. deck_get_archived_stacks.
description_max_length: If set, truncate each card's description description_max_length: If set, truncate each card's description
to this many characters. to this many characters.
""" """
_validate_description_max_length(description_max_length)
client = await get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
cards = _apply_card_filters( cards = _apply_card_filters(
stack.cards or [], stack.cards or [],
include_archived=include_archived, include_archived_cards=include_archived_cards,
description_max_length=description_max_length, description_max_length=description_max_length,
) )
return ListCardsResponse(cards=cards, total=len(cards)) return ListCardsResponse(cards=cards, total=len(cards))
@@ -475,8 +482,8 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
board_id: int, board_id: int,
stack_id: int, stack_id: int,
title: Optional[str] = None, title: str | None = None,
order: Optional[int] = None, order: int | None = None,
) -> StackOperationResponse: ) -> StackOperationResponse:
"""Update a Nextcloud Deck stack """Update a Nextcloud Deck stack
@@ -535,8 +542,8 @@ def configure_deck_tools(mcp: FastMCP):
title: str, title: str,
type: str = "plain", type: str = "plain",
order: int = 999, order: int = 999,
description: Optional[str] = None, description: str | None = None,
duedate: Optional[str] = None, duedate: str | None = None,
) -> CreateCardResponse: ) -> CreateCardResponse:
"""Create a new card in a Nextcloud Deck stack """Create a new card in a Nextcloud Deck stack
@@ -571,14 +578,14 @@ def configure_deck_tools(mcp: FastMCP):
board_id: int, board_id: int,
stack_id: int, stack_id: int,
card_id: int, card_id: int,
title: Optional[str] = None, title: str | None = None,
description: Optional[str] = None, description: str | None = None,
type: Optional[str] = None, type: str | None = None,
owner: Optional[str] = None, owner: str | None = None,
order: Optional[int] = None, order: int | None = None,
duedate: Optional[str] = None, duedate: str | None = None,
archived: Optional[bool] = None, archived: bool | None = None,
done: Optional[str] = None, done: str | None = None,
) -> CardOperationResponse: ) -> CardOperationResponse:
"""Update a Nextcloud Deck card """Update a Nextcloud Deck card
@@ -763,8 +770,8 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
board_id: int, board_id: int,
label_id: int, label_id: int,
title: Optional[str] = None, title: str | None = None,
color: Optional[str] = None, color: str | None = None,
) -> LabelOperationResponse: ) -> LabelOperationResponse:
"""Update a Nextcloud Deck label """Update a Nextcloud Deck label
+1 -1
View File
@@ -283,7 +283,7 @@ async def test_deck_get_archived_stacks(mocker):
assert stacks[0].id == 9 assert stacks[0].id == 9
mock_make_request.assert_called_once() mock_make_request.assert_called_once()
assert "/boards/123/stacks/archived" in mock_make_request.call_args[0][1] assert "/boards/123/stacks/archived" in mock_make_request.call_args.args[1]
# Card Tests # Card Tests
+30 -15
View File
@@ -14,6 +14,7 @@ from nextcloud_mcp_server.server.deck import (
_apply_card_filters, _apply_card_filters,
_apply_stack_filters, _apply_stack_filters,
_truncate_card_descriptions, _truncate_card_descriptions,
_validate_description_max_length,
) )
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -128,18 +129,30 @@ def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
assert cards[0].description == "hello" assert cards[0].description == "hello"
def test_truncate_card_descriptions_rejects_zero(): # _validate_description_max_length ----------------------------------------
"""A zero limit is invalid (would wipe descriptions to a single ellipsis)."""
cards = [_make_card(1, "anything")]
with pytest.raises(ValueError, match="must be positive"):
_truncate_card_descriptions(cards, 0)
def test_truncate_card_descriptions_rejects_negative(): def test_validate_description_max_length_accepts_none():
"""Negative limits are invalid.""" """None is the documented sentinel for "no truncation"."""
cards = [_make_card(1, "anything")] _validate_description_max_length(None)
def test_validate_description_max_length_accepts_positive():
"""Positive values pass through silently."""
_validate_description_max_length(1)
_validate_description_max_length(1000)
def test_validate_description_max_length_rejects_zero():
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"): with pytest.raises(ValueError, match="must be positive"):
_truncate_card_descriptions(cards, -10) _validate_description_max_length(0)
def test_validate_description_max_length_rejects_negative():
"""Negative values produce surprising slice semantics — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"):
_validate_description_max_length(-10)
# _apply_board_filters ------------------------------------------------------ # _apply_board_filters ------------------------------------------------------
@@ -287,23 +300,23 @@ def test_apply_stack_filters_handles_none_cards():
def test_apply_card_filters_excludes_archived_by_default(): def test_apply_card_filters_excludes_archived_by_default():
"""include_archived=False filters archived cards out of the flat list.""" """include_archived_cards=False filters archived cards out of the flat list."""
cards = [ cards = [
_make_card(1, archived=False), _make_card(1, archived=False),
_make_card(2, archived=True), _make_card(2, archived=True),
_make_card(3, archived=False), _make_card(3, archived=False),
] ]
result = _apply_card_filters( result = _apply_card_filters(
cards, include_archived=False, description_max_length=None cards, include_archived_cards=False, description_max_length=None
) )
assert [c.id for c in result] == [1, 3] assert [c.id for c in result] == [1, 3]
def test_apply_card_filters_keeps_archived_when_requested(): def test_apply_card_filters_keeps_archived_when_requested():
"""include_archived=True retains archived cards.""" """include_archived_cards=True retains archived cards."""
cards = [_make_card(1, archived=False), _make_card(2, archived=True)] cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
result = _apply_card_filters( result = _apply_card_filters(
cards, include_archived=True, description_max_length=None cards, include_archived_cards=True, description_max_length=None
) )
assert [c.id for c in result] == [1, 2] assert [c.id for c in result] == [1, 2]
@@ -312,7 +325,7 @@ def test_apply_card_filters_truncates_descriptions():
"""description_max_length is honored on the returned cards.""" """description_max_length is honored on the returned cards."""
cards = [_make_card(1, description="x" * 50)] cards = [_make_card(1, description="x" * 50)]
result = _apply_card_filters( result = _apply_card_filters(
cards, include_archived=True, description_max_length=10 cards, include_archived_cards=True, description_max_length=10
) )
assert result[0].description is not None assert result[0].description is not None
assert result[0].description.endswith("") assert result[0].description.endswith("")
@@ -320,5 +333,7 @@ def test_apply_card_filters_truncates_descriptions():
def test_apply_card_filters_empty_list_is_noop(): def test_apply_card_filters_empty_list_is_noop():
"""An empty input returns an empty output.""" """An empty input returns an empty output."""
result = _apply_card_filters([], include_archived=False, description_max_length=10) result = _apply_card_filters(
[], include_archived_cards=False, description_max_length=10
)
assert result == [] assert result == []