From b11103064c0ccedfa624a29c0ec6f48cb9055631 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 1 Jun 2026 16:17:46 +0200 Subject: [PATCH] feat(deck): compact card/comment retrieval (summaries, filters, board overview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deck read tools returned too many tokens to be usable as boards grow — even deck_get_stacks(description_max_length=1) exceeded the MCP token limit because every card was fully serialized in list views. - Add compact projection models (DeckCardSummary, DeckCommentSummary, StackOverview, BoardOverviewResponse) and a uniform detail="summary"|"full" knob (summary default) on deck_get_cards / get_stacks / get_stack / get_archived_stacks. - Add pre-serialization filtering: status (open/done/archived/all), label, assigned_to. - Add deck_get_board_overview: board title + label legend + stacks with compact card rows + counts in a single call. - Compact comments: detail / message_max_length / newest-first order on deck_get_card_comments. - Docs + unit/integration tests. BREAKING CHANGE: deck list tools now default to detail="summary" and status="open". The include_archived_cards parameter is replaced by status (use status="all" to include archived cards); pass detail="full" to restore the previous per-card shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/deck.md | 48 ++++ nextcloud_mcp_server/models/deck.py | 94 +++++- nextcloud_mcp_server/server/deck.py | 432 ++++++++++++++++++++++++---- tests/server/test_deck_mcp.py | 68 +++++ tests/unit/test_deck_server.py | 330 ++++++++++++++++----- 5 files changed, 830 insertions(+), 142 deletions(-) diff --git a/docs/deck.md b/docs/deck.md index 51a51bf5..dfad7310 100644 --- a/docs/deck.md +++ b/docs/deck.md @@ -4,6 +4,16 @@ | Tool | Description | |------|-------------| +| `deck_get_boards` | List all Deck boards | +| `deck_get_board` | Get a board (toggle `include_acl` / `include_users` / `include_labels`) | +| `deck_get_board_overview` | **Compact whole-board snapshot** — board → stacks → summary card rows in one call | +| `deck_get_stacks` | List stacks in a board (cards as compact summaries by default) | +| `deck_get_stack` | Get a single stack (cards as compact summaries by default) | +| `deck_get_archived_stacks` | List archived stacks and their cards | +| `deck_get_cards` | List cards in a stack (compact summaries by default) | +| `deck_get_card` | Get a single card in full detail | +| `deck_get_labels` / `deck_get_label` | List / get board labels | +| `deck_get_card_comments` | List card comments (compact, newest-first by default) | | `deck_create_board` | Create a new Deck board with title and color | | `deck_create_stack` | Create a new stack in a board | | `deck_update_stack` | Update stack title and order | @@ -36,6 +46,44 @@ +### Compact Retrieval (token efficiency) + +On large boards the full card objects (description, nested labels, assigned +users, attachments, etags) make `deck_get_stacks` responses too large to be +practical. The read tools therefore return **compact card summaries by +default** and support filtering so you fetch only what you need. + +**Shared knobs** on `deck_get_cards`, `deck_get_stacks`, `deck_get_stack` +(and `deck_get_archived_stacks`, minus `status`): + +| Parameter | Default | Effect | +|-----------|---------|--------| +| `detail` | `summary` | `summary` returns compact rows (id, title, stackId, labels as titles, assignee UIDs, due/done, counts, a short `descriptionPreview`); `full` returns the complete card objects (the pre-0.92 shape). | +| `status` | `open` | Filter before serialization: `open` (excludes archived **and** done), `done`, `archived`, or `all`. | +| `label` | – | Only cards carrying a label with this exact title. | +| `assigned_to` | – | Only cards assigned to this user UID. | +| `description_max_length` | – | In `detail="full"`, truncate each description. | +| `description_preview_length` | `140` | In `detail="summary"`, length of the preview. | + +**`deck_get_board_overview(board_id, status="open", label=…, assigned_to=…)`** +is the token-efficient way to see a whole board: it returns the board title, +its label legend, and every stack with compact card rows in a single call — +prefer it over `deck_get_board` + `deck_get_stacks` for "show me the board" +requests. Use `deck_get_card` for the full body of a specific card. + +**Comments** — `deck_get_card_comments` returns compact comments +(`id`, `actorId`, `message`, `creationDateTime`) by default. Use +`detail="full"` for the complete objects, `message_max_length` to truncate, +`order` (`newest`/`oldest`) to sort the page, and `limit`/`offset` to page. + +> **Breaking change:** list tools now default to `detail="summary"` +> and `status="open"`. The previous `include_archived_cards` parameter has +> been replaced by `status` (`status="all"` includes archived cards, matching +> `include_archived_cards=True`). Pass `detail="full"` to restore the old +> per-card shape. + + + ### Deck Project Management The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows: diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index 1c29c401..37f7261b 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -129,6 +129,44 @@ class DeckCard(BaseModel): return validated_users +class DeckCardSummary(BaseModel): + """Compact projection of a :class:`DeckCard` for list/overview views. + + Drops the heavy fields that dominate token cost when many cards are + returned at once — the full ``description`` (kept only as a short + ``descriptionPreview``), the nested ``labels``/``assignedUsers``/ + ``attachments`` objects (kept as flat title/uid lists + counts), and the + ``etag``/``order``/``*Modified``/``createdAt``/``deletedAt``/``overdue``/ + ``type``/``owner`` bookkeeping fields. Fetch a single card with + ``deck_get_card`` (or ``detail="full"``) when the full body is needed. + """ + + id: int + title: str + stackId: int + archived: bool = False + duedate: Optional[datetime] = None + done: Optional[datetime] = None + labels: List[str] = Field( + default_factory=list, description="Label titles assigned to the card" + ) + assignedUsers: List[str] = Field( + default_factory=list, description="UIDs of users assigned to the card" + ) + attachmentCount: Optional[int] = Field( + default=None, description="Number of attachments on the card" + ) + commentsUnread: Optional[int] = Field( + default=None, description="Number of unread comments (Deck exposes no total)" + ) + hasDescription: bool = Field( + default=False, description="Whether the card has a non-empty description" + ) + descriptionPreview: Optional[str] = Field( + default=None, description="Truncated preview of the card description" + ) + + class DeckStack(BaseModel): id: int title: str @@ -136,7 +174,9 @@ class DeckStack(BaseModel): order: int deletedAt: int lastModified: Optional[int] = None - cards: Optional[List[DeckCard]] = None + # Cards may be projected to DeckCardSummary when a tool is called with + # detail="summary" (the default for list tools). + cards: Optional[List[Union[DeckCard, DeckCardSummary]]] = None etag: Optional[str] = Field(default=None, alias="ETag") @@ -175,6 +215,20 @@ class DeckComment(BaseModel): replyTo: Optional[Any] = None # Self-referencing, handle later if needed +class DeckCommentSummary(BaseModel): + """Compact projection of a :class:`DeckComment` for list views. + + Drops ``mentions``, ``actorType``, ``actorDisplayName`` and ``replyTo``, + keeping only the fields needed to read the conversation. Long messages are + truncated by the tool layer via ``message_max_length``. + """ + + id: int + actorId: str + message: str + creationDateTime: datetime + + class DeckSession(BaseModel): token: str @@ -220,6 +274,36 @@ class ListStacksResponse(BaseResponse): total: int = Field(description="Total number of stacks") +class StackOverview(BaseModel): + """A stack plus its compact card rows, for the board-overview tool.""" + + id: int = Field(description="Stack ID") + title: str = Field(description="Stack title") + order: Optional[int] = Field(default=None, description="Stack sort order") + card_count: int = Field(description="Number of cards returned for this stack") + cards: List[DeckCardSummary] = Field( + default_factory=list, description="Compact card rows in this stack" + ) + + +class BoardOverviewResponse(BaseResponse): + """Compact whole-board snapshot: board → stacks → summary card rows. + + A single-call replacement for deck_get_board + deck_get_stacks that keeps + the response small enough to fit the token budget on large boards. + """ + + board_id: int = Field(description="Board ID") + title: str = Field(description="Board title") + labels: List[str] = Field( + default_factory=list, description="Board label titles (legend)" + ) + stacks: List[StackOverview] = Field( + default_factory=list, description="Stacks with compact card rows" + ) + total_cards: int = Field(description="Total cards across all returned stacks") + + class CreateStackResponse(BaseResponse): """Response model for stack creation.""" @@ -269,7 +353,9 @@ class CreateLabelResponse(BaseResponse): class ListCardsResponse(BaseResponse): """Response model for listing deck cards.""" - cards: list[DeckCard] = Field(description="List of deck cards") + cards: list[Union[DeckCard, DeckCardSummary]] = Field( + description="List of deck cards (summaries unless detail='full')" + ) total: int = Field(description="Total number of cards") @@ -293,7 +379,9 @@ class LabelOperationResponse(StatusResponse): class ListCardCommentsResponse(BaseResponse): """Response model for listing card comments.""" - results: list[DeckComment] = Field(description="Card comments in this page") + results: list[Union[DeckComment, DeckCommentSummary]] = Field( + description="Card comments in this page (summaries unless detail='full')" + ) count: int = Field( description=( "Number of comments returned in this page (page size, not the " diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 296a3f09..e9bcf6ee 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -1,4 +1,5 @@ import logging +from typing import Literal, cast import anyio from mcp.server.fastmcp import Context, FastMCP @@ -9,6 +10,7 @@ from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( AttachFileResponse, AttachmentOperationResponse, + BoardOverviewResponse, CardCommentOperationResponse, CardCommentResponse, CardOperationResponse, @@ -16,10 +18,15 @@ from nextcloud_mcp_server.models.deck import ( CreateCardResponse, CreateLabelResponse, CreateStackResponse, + DeckAssignedUser, DeckBoard, DeckCard, + DeckCardSummary, + DeckComment, + DeckCommentSummary, DeckLabel, DeckStack, + DeckUser, LabelOperationResponse, ListAttachmentsResponse, ListBoardsResponse, @@ -28,18 +35,33 @@ from nextcloud_mcp_server.models.deck import ( ListLabelsResponse, ListStacksResponse, StackOperationResponse, + StackOverview, ) from nextcloud_mcp_server.observability.metrics import instrument_tool logger = logging.getLogger(__name__) +# Card status filter applied before serialization. "open" (the default for +# list tools) hides archived and explicitly-done cards — the actionable set. +CardStatus = Literal["all", "open", "done", "archived"] +# Per-card detail level. "summary" (the default for list tools) projects each +# card to a compact DeckCardSummary; "full" returns the heavy DeckCard. +DetailLevel = Literal["summary", "full"] +# Default length for the description preview carried in card summaries. +_DEFAULT_DESCRIPTION_PREVIEW = 140 -def _validate_description_max_length(description_max_length: int | None) -> None: - """Tool-layer guard: reject zero/negative truncation thresholds.""" - if description_max_length is not None and description_max_length <= 0: - raise ValueError( - f"description_max_length must be positive, got {description_max_length}" - ) + +def _validate_description_max_length( + value: int | None, name: str = "description_max_length" +) -> None: + """Tool-layer guard: reject zero/negative length thresholds. + + Reused for every positive-length knob (description truncation/preview, + comment message truncation); ``name`` keeps the error message pointed at + the parameter the caller actually passed. + """ + if value is not None and value <= 0: + raise ValueError(f"{name} must be positive, got {value}") def _truncate_card_descriptions( @@ -71,38 +93,157 @@ def _apply_board_filters( return board +def _extract_uid(user: "DeckUser | DeckAssignedUser") -> str | None: + """Pull the bare UID out of either assigned-user shape the API returns.""" + if isinstance(user, DeckAssignedUser): + return user.participant.uid + if isinstance(user, DeckUser): + return user.uid + return None + + +def _filter_cards( + cards: list[DeckCard], + *, + status: CardStatus, + label: str | None, + assigned_to: str | None, +) -> list[DeckCard]: + """Narrow a flat card list by status/label/assignee before serialization. + + The upstream Deck API returns every card (including archived ones) inline, + so this filtering reduces the tokens the caller sees but not network + bandwidth. ``status="open"`` excludes archived and explicitly-done cards. + """ + if status == "open": + cards = [c for c in cards if not c.archived and c.done is None] + elif status == "done": + cards = [c for c in cards if c.done is not None] + elif status == "archived": + cards = [c for c in cards if c.archived] + # status == "all": no status filter + + if label is not None: + cards = [ + c for c in cards if any(lbl.title == label for lbl in (c.labels or [])) + ] + if assigned_to is not None: + cards = [ + c + for c in cards + if assigned_to in {_extract_uid(u) for u in (c.assignedUsers or [])} + ] + return cards + + +def _summarize_card(card: DeckCard, description_preview_length: int) -> DeckCardSummary: + """Project a full DeckCard down to its compact DeckCardSummary.""" + description = card.description or "" + has_description = bool(description.strip()) + preview: str | None = None + if has_description: + preview = description[:description_preview_length] + if len(description) > description_preview_length: + preview += "…" + assignees = [ + uid for u in (card.assignedUsers or []) if (uid := _extract_uid(u)) is not None + ] + return DeckCardSummary( + id=card.id, + title=card.title, + stackId=card.stackId, + archived=card.archived, + duedate=card.duedate, + done=card.done, + labels=[lbl.title for lbl in (card.labels or [])], + assignedUsers=assignees, + attachmentCount=card.attachmentCount, + commentsUnread=card.commentsUnread, + hasDescription=has_description, + descriptionPreview=preview, + ) + + +def _shape_cards( + cards: list[DeckCard], + *, + detail: DetailLevel, + status: CardStatus, + label: str | None, + assigned_to: str | None, + description_max_length: int | None, + description_preview_length: int, +) -> list[DeckCard | DeckCardSummary]: + """Filter then project a card list according to the requested detail level.""" + filtered = _filter_cards(cards, status=status, label=label, assigned_to=assigned_to) + if detail == "full": + _truncate_card_descriptions(filtered, description_max_length) + return list(filtered) + return [_summarize_card(c, description_preview_length) for c in filtered] + + def _apply_stack_filters( stack: DeckStack, *, include_cards: bool, - include_archived_cards: bool, + detail: DetailLevel, + status: CardStatus, + label: str | None, + assigned_to: str | None, description_max_length: int | None, + description_preview_length: int, ) -> DeckStack: - """Apply card-shaping filters to a single stack; returns the stack.""" - # 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. + """Apply card filtering + projection to a single stack; returns the stack.""" if not include_cards: stack.cards = None elif stack.cards: - if not include_archived_cards: - stack.cards = [c for c in stack.cards if not c.archived] - _truncate_card_descriptions(stack.cards, description_max_length) + # Cards come straight from the client as DeckCard; the field type is a + # union only because summary projection writes summaries back into it. + stack.cards = _shape_cards( + cast(list[DeckCard], stack.cards), + detail=detail, + status=status, + label=label, + assigned_to=assigned_to, + description_max_length=description_max_length, + description_preview_length=description_preview_length, + ) return stack -def _apply_card_filters( - cards: list[DeckCard], +def _truncate_comment_message(message: str, message_max_length: int | None) -> str: + """Truncate a comment strictly longer than the limit; appends "…".""" + if message_max_length is not None and len(message) > message_max_length: + return message[:message_max_length] + "…" + return message + + +def _shape_comments( + comments: list[DeckComment], *, - include_archived_cards: bool, - description_max_length: int | None, -) -> list[DeckCard]: - """Apply filters to a flat list of cards; returns the (possibly new) list.""" - if not include_archived_cards: - cards = [c for c in cards if not c.archived] - _truncate_card_descriptions(cards, description_max_length) - return cards + detail: DetailLevel, + message_max_length: int | None, + order: Literal["newest", "oldest"], +) -> list[DeckComment | DeckCommentSummary]: + """Order, truncate and (optionally) project a page of card comments.""" + ordered = sorted( + comments, key=lambda c: c.creationDateTime, reverse=(order == "newest") + ) + if detail == "full": + for comment in ordered: + comment.message = _truncate_comment_message( + comment.message, message_max_length + ) + return list(ordered) + return [ + DeckCommentSummary( + id=c.id, + actorId=c.actorId, + message=_truncate_comment_message(c.message, message_max_length), + creationDateTime=c.creationDateTime, + ) + for c in ordered + ] # Card attachments — file shares ("Share from Files" picker in the Deck UI). @@ -311,31 +452,52 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, include_cards: bool = True, - include_archived_cards: bool = False, + detail: DetailLevel = "summary", + status: CardStatus = "open", + label: str | None = None, + assigned_to: str | None = None, description_max_length: int | None = None, + description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW, ) -> ListStacksResponse: """Get all stacks in a Nextcloud Deck board. + Cards are returned as compact summaries by default to keep the + response small on large boards. Filtering/projection happen + client-side after the API returns the full board, so they reduce the + tokens the caller sees but not network bandwidth. + Args: board_id: The ID of the board include_cards: Include cards inside each stack (default True). Set False for a lightweight stack listing; fetch cards separately via deck_get_cards. - include_archived_cards: Include archived cards (default False). - Only relevant when include_cards is True. - description_max_length: If set, truncate each card's description - to this many characters. Useful for keeping responses compact - on boards with long card specs. + detail: "summary" (default) returns compact card rows; "full" + returns the complete card objects (the old behavior). + status: Which cards to include — "open" (default; excludes + archived and done), "done", "archived", or "all". + label: If set, only cards carrying a label with this exact title. + assigned_to: If set, only cards assigned to this user UID. + description_max_length: In detail="full", truncate each card's + description to this many characters. + description_preview_length: In detail="summary", length of the + description preview carried on each card (default 140). """ _validate_description_max_length(description_max_length) + _validate_description_max_length( + description_preview_length, "description_preview_length" + ) client = await get_client(ctx) stacks = await client.deck.get_stacks(board_id) stacks = [ _apply_stack_filters( stack, include_cards=include_cards, - include_archived_cards=include_archived_cards, + detail=detail, + status=status, + label=label, + assigned_to=assigned_to, description_max_length=description_max_length, + description_preview_length=description_preview_length, ) for stack in stacks ] @@ -352,28 +514,44 @@ def configure_deck_tools(mcp: FastMCP): board_id: int, stack_id: int, include_cards: bool = True, - include_archived_cards: bool = False, + detail: DetailLevel = "summary", + status: CardStatus = "open", + label: str | None = None, + assigned_to: str | None = None, description_max_length: int | None = None, + description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW, ) -> DeckStack: """Get details of a specific Nextcloud Deck stack. + Cards are returned as compact summaries by default; see + deck_get_stacks for the shared parameter semantics. + Args: board_id: The ID of the board stack_id: The ID of the stack include_cards: Include cards in the stack (default True). - include_archived_cards: Include archived cards (default False). - Only relevant when include_cards is True. - description_max_length: If set, truncate each card's description - to this many characters. + detail: "summary" (default) or "full". + status: "open" (default), "done", "archived", or "all". + label: If set, only cards carrying a label with this exact title. + assigned_to: If set, only cards assigned to this user UID. + description_max_length: In detail="full", truncate descriptions. + description_preview_length: In detail="summary", preview length. """ _validate_description_max_length(description_max_length) + _validate_description_max_length( + description_preview_length, "description_preview_length" + ) client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) return _apply_stack_filters( stack, include_cards=include_cards, - include_archived_cards=include_archived_cards, + detail=detail, + status=status, + label=label, + assigned_to=assigned_to, description_max_length=description_max_length, + description_preview_length=description_preview_length, ) @mcp.tool( @@ -385,7 +563,9 @@ def configure_deck_tools(mcp: FastMCP): async def deck_get_archived_stacks( ctx: Context, board_id: int, + detail: DetailLevel = "summary", description_max_length: int | None = None, + description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW, ) -> ListStacksResponse: """List archived stacks (with their archived cards) for a Nextcloud Deck board. @@ -395,26 +575,33 @@ def configure_deck_tools(mcp: FastMCP): archived via deck_archive_card). The shape mirrors deck_get_stacks. Cards are always included on the returned stacks (an archived stack - without its cards would have no audit value); pass - ``description_max_length`` if you need to keep the response compact. + without its cards would have no audit value) and returned as compact + summaries by default. Args: board_id: The ID of the board - description_max_length: If set, truncate each card's description - to this many characters. + detail: "summary" (default) or "full". + description_max_length: In detail="full", truncate descriptions. + description_preview_length: In detail="summary", preview length. """ _validate_description_max_length(description_max_length) + _validate_description_max_length( + description_preview_length, "description_preview_length" + ) client = await get_client(ctx) stacks = await client.deck.get_archived_stacks(board_id) - # All cards in archived stacks are themselves archived; route through - # the same helper as the active-stack path so future filter additions - # apply uniformly. + # All cards in archived stacks are themselves archived; status="all" + # keeps them (an "open"/"done" filter would drop the whole point). stacks = [ _apply_stack_filters( stack, include_cards=True, - include_archived_cards=True, + detail=detail, + status="all", + label=None, + assigned_to=None, description_max_length=description_max_length, + description_preview_length=description_preview_length, ) for stack in stacks ] @@ -430,36 +617,133 @@ def configure_deck_tools(mcp: FastMCP): ctx: Context, board_id: int, stack_id: int, - include_archived_cards: bool = False, + detail: DetailLevel = "summary", + status: CardStatus = "open", + label: str | None = None, + assigned_to: str | None = None, description_max_length: int | None = None, + description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW, ) -> ListCardsResponse: """Get all cards in a Nextcloud Deck stack. - Filtering is applied client-side after the API returns the full - stack, so ``include_archived_cards=False`` and - ``description_max_length`` reduce response size visible to the - caller but not network bandwidth — network-wise this tool is - equivalent to deck_get_stack(include_cards=True). + Cards are returned as compact summaries by default. Filtering and + projection are applied client-side after the API returns the full + stack, so they reduce the tokens the caller sees but not network + bandwidth — network-wise this tool is equivalent to + deck_get_stack(include_cards=True). Args: board_id: The ID of the board stack_id: The ID of the stack - include_archived_cards: Include archived cards (default False). - Archived cards can also be retrieved per-board via - deck_get_archived_stacks. - description_max_length: If set, truncate each card's description - to this many characters. + detail: "summary" (default) returns compact card rows; "full" + returns the complete card objects. + status: "open" (default; excludes archived and done), "done", + "archived", or "all". + label: If set, only cards carrying a label with this exact title. + assigned_to: If set, only cards assigned to this user UID. + description_max_length: In detail="full", truncate descriptions. + description_preview_length: In detail="summary", preview length. """ _validate_description_max_length(description_max_length) + _validate_description_max_length( + description_preview_length, "description_preview_length" + ) client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) - cards = _apply_card_filters( - stack.cards or [], - include_archived_cards=include_archived_cards, + cards = _shape_cards( + cast(list[DeckCard], stack.cards or []), + detail=detail, + status=status, + label=label, + assigned_to=assigned_to, description_max_length=description_max_length, + description_preview_length=description_preview_length, ) return ListCardsResponse(cards=cards, total=len(cards)) + @mcp.tool( + title="Get Deck Board Overview", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) + @require_scopes("deck.read") + @instrument_tool + async def deck_get_board_overview( + ctx: Context, + board_id: int, + status: CardStatus = "open", + label: str | None = None, + assigned_to: str | None = None, + description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW, + ) -> BoardOverviewResponse: + """Get a compact, whole-board snapshot in a single call. + + Returns the board title, its label legend, and every stack with its + cards projected to compact summary rows. This is the token-efficient + replacement for calling deck_get_board + deck_get_stacks when you just + need to see the state of the board — prefer it for "show me the + board" / "what's in progress" style requests on large boards. + + Args: + board_id: The ID of the board + status: Which cards to include — "open" (default; excludes + archived and done), "done", "archived", or "all". + label: If set, only cards carrying a label with this exact title. + assigned_to: If set, only cards assigned to this user UID. + description_preview_length: Length of the description preview + carried on each card summary (default 140). + """ + _validate_description_max_length( + description_preview_length, "description_preview_length" + ) + client = await get_client(ctx) + + board_holder: list[DeckBoard] = [] + stacks_holder: list[list[DeckStack]] = [] + + async def _get_board() -> None: + board_holder.append(await client.deck.get_board(board_id)) + + async def _get_stacks() -> None: + stacks_holder.append(await client.deck.get_stacks(board_id)) + + async with anyio.create_task_group() as tg: + tg.start_soon(_get_board) + tg.start_soon(_get_stacks) + + board = board_holder[0] + stacks = stacks_holder[0] + + stack_overviews: list[StackOverview] = [] + total_cards = 0 + for stack in stacks: + summaries = [ + _summarize_card(c, description_preview_length) + for c in _filter_cards( + cast(list[DeckCard], stack.cards or []), + status=status, + label=label, + assigned_to=assigned_to, + ) + ] + total_cards += len(summaries) + stack_overviews.append( + StackOverview( + id=stack.id, + title=stack.title, + order=stack.order, + card_count=len(summaries), + cards=summaries, + ) + ) + + return BoardOverviewResponse( + board_id=board.id, + title=board.title, + labels=[lbl.title for lbl in (board.labels or [])], + stacks=stack_overviews, + total_cards=total_cards, + ) + @mcp.tool( title="Get Deck Card", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), @@ -1016,18 +1300,40 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck.read") @instrument_tool async def deck_get_card_comments( - ctx: Context, card_id: int, limit: int = 20, offset: int = 0 + ctx: Context, + card_id: int, + limit: int = 20, + offset: int = 0, + detail: DetailLevel = "summary", + message_max_length: int | None = None, + order: Literal["newest", "oldest"] = "newest", ) -> ListCardCommentsResponse: - """List comments on a Nextcloud Deck card + """List comments on a Nextcloud Deck card. + + Returns compact comments by default (dropping mentions, actor type and + display name). Ordering and truncation apply within the returned page. Args: card_id: The ID of the card limit: Maximum number of comments to return (default 20, max 200) offset: Pagination offset (default 0) + detail: "summary" (default) returns compact comments; "full" + returns the complete comment objects. + message_max_length: If set, truncate each comment message to this + many characters. + order: "newest" (default) or "oldest" — sort the page by creation + time. """ + _validate_description_max_length(message_max_length, "message_max_length") client = await get_client(ctx) comments = await client.deck.get_comments(card_id, limit=limit, offset=offset) - return ListCardCommentsResponse(results=comments, count=len(comments)) + shaped = _shape_comments( + comments, + detail=detail, + message_max_length=message_max_length, + order=order, + ) + return ListCardCommentsResponse(results=shaped, count=len(shaped)) @mcp.tool( title="Create Deck Card Comment", diff --git a/tests/server/test_deck_mcp.py b/tests/server/test_deck_mcp.py index 0bcaedfa..51b9fe21 100644 --- a/tests/server/test_deck_mcp.py +++ b/tests/server/test_deck_mcp.py @@ -353,3 +353,71 @@ async def test_deck_card_comment_message_too_long_mcp( {"card_id": card_id, "message": too_long}, ) assert result.isError is True, "Expected validation error for >1000 char message" + + +# Compact retrieval (summary projection + board overview) + + +async def test_deck_get_stacks_summary_default_omits_full_card_fields_mcp( + nc_mcp_client: ClientSession, temporary_board_with_card: tuple +): + """deck_get_stacks defaults to compact summaries: the card row carries + title/labels but not the heavy full-card fields (owner/type/etag).""" + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + + result = await nc_mcp_client.call_tool("deck_get_stacks", {"board_id": board_id}) + assert result.isError is False, f"deck_get_stacks failed: {result.content}" + payload = json.loads(result.content[0].text) + + cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])] + card = next(c for c in cards if c["id"] == card_data["id"]) + # Summary fields present... + assert card["title"] == card_data["title"] + assert "hasDescription" in card + assert "labels" in card and isinstance(card["labels"], list) + # ...heavy full-card fields absent. + assert "owner" not in card + assert "type" not in card + + +async def test_deck_get_stacks_detail_full_keeps_card_fields_mcp( + nc_mcp_client: ClientSession, temporary_board_with_card: tuple +): + """detail="full" restores the complete card objects (owner/type present).""" + board_data, _, card_data = temporary_board_with_card + board_id = board_data["id"] + + result = await nc_mcp_client.call_tool( + "deck_get_stacks", {"board_id": board_id, "detail": "full"} + ) + assert result.isError is False, f"deck_get_stacks(full) failed: {result.content}" + payload = json.loads(result.content[0].text) + + cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])] + card = next(c for c in cards if c["id"] == card_data["id"]) + assert "owner" in card + assert "type" in card + + +async def test_deck_get_board_overview_mcp( + nc_mcp_client: ClientSession, temporary_board_with_card: tuple +): + """deck_get_board_overview returns board title + stacks with compact rows.""" + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + + result = await nc_mcp_client.call_tool( + "deck_get_board_overview", {"board_id": board_id} + ) + assert result.isError is False, f"board overview failed: {result.content}" + payload = json.loads(result.content[0].text) + + assert payload["board_id"] == board_id + assert payload["title"] == board_data["title"] + assert payload["total_cards"] >= 1 + + stack = next(s for s in payload["stacks"] if s["id"] == stack_data["id"]) + assert stack["card_count"] == len(stack["cards"]) + card_ids = [c["id"] for c in stack["cards"]] + assert card_data["id"] in card_ids diff --git a/tests/unit/test_deck_server.py b/tests/unit/test_deck_server.py index ff0ab859..7fac6b30 100644 --- a/tests/unit/test_deck_server.py +++ b/tests/unit/test_deck_server.py @@ -1,9 +1,15 @@ +from datetime import datetime, timezone + import pytest from nextcloud_mcp_server.models.deck import ( DeckACL, + DeckAssignedUser, DeckBoard, DeckCard, + DeckCardSummary, + DeckComment, + DeckCommentSummary, DeckLabel, DeckPermissions, DeckStack, @@ -12,11 +18,16 @@ from nextcloud_mcp_server.models.deck import ( from nextcloud_mcp_server.server.deck import ( _SHARE_TYPE_DECK, _apply_board_filters, - _apply_card_filters, _apply_stack_filters, + _extract_uid, + _filter_cards, _resolve_note_attach_path, _resolve_note_path, + _shape_cards, + _shape_comments, + _summarize_card, _truncate_card_descriptions, + _truncate_comment_message, _validate_description_max_length, ) @@ -30,6 +41,12 @@ def _make_card( card_id: int, description: str | None = "desc", archived: bool = False, + *, + done: datetime | None = None, + labels: list[DeckLabel] | None = None, + assigned_users: list | None = None, + attachment_count: int | None = None, + comments_unread: int | None = None, ) -> DeckCard: return DeckCard( id=card_id, @@ -40,6 +57,30 @@ def _make_card( archived=archived, owner="testuser", description=description, + done=done, + labels=labels, + assignedUsers=assigned_users, + attachmentCount=attachment_count, + commentsUnread=comments_unread, + ) + + +def _make_comment( + comment_id: int, + message: str = "hello", + *, + actor: str = "alice", + created: datetime | None = None, +) -> DeckComment: + return DeckComment( + id=comment_id, + objectId=1, + message=message, + actorId=actor, + actorType="users", + actorDisplayName=actor.title(), + creationDateTime=created or datetime(2024, 1, comment_id, tzinfo=timezone.utc), + mentions=[], ) @@ -231,67 +272,210 @@ def test_apply_board_filters_excludes_all(): assert result.labels == [] +# Shared default kwargs for _apply_stack_filters in summary mode ------------ + +_STACK_DEFAULTS = dict( + detail="summary", + status="open", + label=None, + assigned_to=None, + description_max_length=None, + description_preview_length=140, +) + + +# _filter_cards ------------------------------------------------------------- + + +def test_filter_cards_open_excludes_archived_and_done(): + """status="open" drops both archived and explicitly-done cards.""" + done_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + cards = [ + _make_card(1), + _make_card(2, archived=True), + _make_card(3, done=done_at), + ] + result = _filter_cards(cards, status="open", label=None, assigned_to=None) + assert [c.id for c in result] == [1] + + +def test_filter_cards_done_keeps_only_done(): + """status="done" keeps only cards with a done timestamp.""" + done_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + cards = [_make_card(1), _make_card(2, done=done_at)] + result = _filter_cards(cards, status="done", label=None, assigned_to=None) + assert [c.id for c in result] == [2] + + +def test_filter_cards_archived_keeps_only_archived(): + """status="archived" keeps only archived cards.""" + cards = [_make_card(1), _make_card(2, archived=True)] + result = _filter_cards(cards, status="archived", label=None, assigned_to=None) + assert [c.id for c in result] == [2] + + +def test_filter_cards_all_keeps_everything(): + """status="all" applies no status filter.""" + cards = [_make_card(1), _make_card(2, archived=True)] + result = _filter_cards(cards, status="all", label=None, assigned_to=None) + assert [c.id for c in result] == [1, 2] + + +def test_filter_cards_by_label_matches_title_exactly(): + """label filtering matches the exact label title.""" + a = _make_card(1, labels=[DeckLabel(id=1, title="bug", color="f00")]) + b = _make_card(2, labels=[DeckLabel(id=2, title="feature", color="0f0")]) + result = _filter_cards([a, b], status="all", label="bug", assigned_to=None) + assert [c.id for c in result] == [1] + + +def test_filter_cards_by_assignee_handles_both_user_shapes(): + """assigned_to matches DeckUser and DeckAssignedUser shapes alike.""" + direct = _make_card(1, assigned_users=[_make_user("alice")]) + wrapped = _make_card( + 2, + assigned_users=[ + DeckAssignedUser(id=9, participant=_make_user("bob"), cardId=2, type=0) + ], + ) + result = _filter_cards( + [direct, wrapped], status="all", label=None, assigned_to="bob" + ) + assert [c.id for c in result] == [2] + + +# _extract_uid -------------------------------------------------------------- + + +def test_extract_uid_from_deck_user(): + assert _extract_uid(_make_user("alice")) == "alice" + + +def test_extract_uid_from_assigned_user(): + assigned = DeckAssignedUser(id=1, participant=_make_user("bob"), cardId=1, type=0) + assert _extract_uid(assigned) == "bob" + + +# _summarize_card ----------------------------------------------------------- + + +def test_summarize_card_projects_compact_fields(): + """Summary carries flat label titles, assignee uids, counts, and a preview.""" + card = _make_card( + 1, + description="x" * 50, + labels=[DeckLabel(id=1, title="bug", color="f00")], + assigned_users=[_make_user("alice")], + attachment_count=3, + comments_unread=2, + ) + summary = _summarize_card(card, description_preview_length=10) + assert isinstance(summary, DeckCardSummary) + assert summary.labels == ["bug"] + assert summary.assignedUsers == ["alice"] + assert summary.attachmentCount == 3 + assert summary.commentsUnread == 2 + assert summary.hasDescription is True + assert summary.descriptionPreview is not None + assert summary.descriptionPreview.endswith("…") + assert len(summary.descriptionPreview) == 11 # 10 chars + ellipsis + + +def test_summarize_card_short_description_has_no_ellipsis(): + """A description within the preview length is carried verbatim.""" + summary = _summarize_card(_make_card(1, description="hi"), 140) + assert summary.descriptionPreview == "hi" + assert summary.hasDescription is True + + +def test_summarize_card_empty_description(): + """An empty/whitespace description yields hasDescription=False, no preview.""" + summary = _summarize_card(_make_card(1, description=" "), 140) + assert summary.hasDescription is False + assert summary.descriptionPreview is None + + +# _shape_cards -------------------------------------------------------------- + + +def test_shape_cards_summary_returns_summaries(): + """detail="summary" projects every (filtered) card to a DeckCardSummary.""" + cards = [_make_card(1), _make_card(2, archived=True)] + result = _shape_cards( + cards, + detail="summary", + status="open", + label=None, + assigned_to=None, + description_max_length=None, + description_preview_length=140, + ) + assert [type(c) for c in result] == [DeckCardSummary] + assert result[0].id == 1 + + +def test_shape_cards_full_returns_truncated_full_cards(): + """detail="full" returns DeckCard objects with descriptions truncated.""" + cards = [_make_card(1, description="x" * 50)] + result = _shape_cards( + cards, + detail="full", + status="all", + label=None, + assigned_to=None, + description_max_length=10, + description_preview_length=140, + ) + assert isinstance(result[0], DeckCard) + assert result[0].description is not None + assert result[0].description.endswith("…") + + # _apply_stack_filters ------------------------------------------------------ def test_apply_stack_filters_include_cards_false_strips_cards(): """include_cards=False sets cards to None regardless of other flags.""" stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)]) - result = _apply_stack_filters( - stack, - include_cards=False, - include_archived_cards=True, - description_max_length=None, - ) + result = _apply_stack_filters(stack, include_cards=False, **_STACK_DEFAULTS) assert result.cards is None -def test_apply_stack_filters_excludes_archived_by_default(): - """include_archived_cards=False filters out archived cards.""" +def test_apply_stack_filters_summary_excludes_archived_by_default(): + """Default status="open" filters out archived cards and returns summaries.""" stack = _make_stack( cards=[_make_card(1, archived=False), _make_card(2, archived=True)] ) - result = _apply_stack_filters( - stack, - include_cards=True, - include_archived_cards=False, - description_max_length=None, - ) + result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS) assert result.cards is not None assert [c.id for c in result.cards] == [1] + assert all(isinstance(c, DeckCardSummary) for c in result.cards) -def test_apply_stack_filters_keeps_archived_when_requested(): - """include_archived_cards=True retains archived cards.""" +def test_apply_stack_filters_status_all_keeps_archived(): + """status="all" retains archived cards.""" stack = _make_stack( cards=[_make_card(1, archived=False), _make_card(2, archived=True)] ) - result = _apply_stack_filters( - stack, - include_cards=True, - include_archived_cards=True, - description_max_length=None, - ) + kwargs = {**_STACK_DEFAULTS, "status": "all"} + result = _apply_stack_filters(stack, include_cards=True, **kwargs) assert result.cards is not None assert [c.id for c in result.cards] == [1, 2] -def test_apply_stack_filters_truncates_descriptions_after_archive_filter(): - """Truncation runs on the post-archive-filter card set.""" +def test_apply_stack_filters_full_truncates_descriptions_after_filter(): + """detail="full" truncation runs on the post-status-filter card set.""" stack = _make_stack( cards=[ _make_card(1, description="x" * 50, archived=False), _make_card(2, description="y" * 50, archived=True), ] ) - result = _apply_stack_filters( - stack, - include_cards=True, - include_archived_cards=False, - description_max_length=10, - ) + kwargs = {**_STACK_DEFAULTS, "detail": "full", "description_max_length": 10} + result = _apply_stack_filters(stack, include_cards=True, **kwargs) assert result.cards is not None assert len(result.cards) == 1 + assert isinstance(result.cards[0], DeckCard) assert result.cards[0].description is not None assert result.cards[0].description.endswith("…") @@ -299,78 +483,72 @@ def test_apply_stack_filters_truncates_descriptions_after_archive_filter(): def test_apply_stack_filters_handles_none_cards(): """A stack with no cards (cards=None) is left untouched.""" stack = _make_stack(cards=None) - result = _apply_stack_filters( - stack, - include_cards=True, - include_archived_cards=False, - description_max_length=10, - ) + result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS) assert result.cards is None -def test_apply_stack_filters_all_archived_yields_empty_list_not_none(): - """A stack whose cards are all archived yields cards == [], not None. +def test_apply_stack_filters_all_filtered_yields_empty_list_not_none(): + """A stack whose cards are all filtered out yields cards == [], not None. Pin the contract: include_cards=True with all cards filtered out means "the stack was loaded but had nothing to show", which is semantically distinct from include_cards=False (cards=None, - "explicitly suppressed"). Callers checking ``stack.cards is None`` - can use that to distinguish the two states. + "explicitly suppressed"). """ stack = _make_stack( cards=[_make_card(1, archived=True), _make_card(2, archived=True)] ) - result = _apply_stack_filters( - stack, - include_cards=True, - include_archived_cards=False, - description_max_length=None, - ) + result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS) assert result.cards == [] assert result.cards is not None -# _apply_card_filters ------------------------------------------------------- +# _shape_comments ----------------------------------------------------------- -def test_apply_card_filters_excludes_archived_by_default(): - """include_archived_cards=False filters archived cards out of the flat list.""" - cards = [ - _make_card(1, archived=False), - _make_card(2, archived=True), - _make_card(3, archived=False), - ] - result = _apply_card_filters( - cards, include_archived_cards=False, description_max_length=None +def test_shape_comments_summary_drops_actor_metadata(): + """detail="summary" projects comments to compact DeckCommentSummary rows.""" + comments = [_make_comment(1, "hi", actor="alice")] + result = _shape_comments( + comments, detail="summary", message_max_length=None, order="newest" ) - assert [c.id for c in result] == [1, 3] + assert isinstance(result[0], DeckCommentSummary) + assert result[0].actorId == "alice" + assert result[0].message == "hi" -def test_apply_card_filters_keeps_archived_when_requested(): - """include_archived_cards=True retains archived cards.""" - cards = [_make_card(1, archived=False), _make_card(2, archived=True)] - result = _apply_card_filters( - cards, include_archived_cards=True, description_max_length=None +def test_shape_comments_newest_first(): + """order="newest" sorts the page by creation time descending.""" + comments = [_make_comment(1), _make_comment(3), _make_comment(2)] + result = _shape_comments( + comments, detail="summary", message_max_length=None, order="newest" ) - assert [c.id for c in result] == [1, 2] + assert [c.id for c in result] == [3, 2, 1] -def test_apply_card_filters_truncates_descriptions(): - """description_max_length is honored on the returned cards.""" - cards = [_make_card(1, description="x" * 50)] - result = _apply_card_filters( - cards, include_archived_cards=True, description_max_length=10 +def test_shape_comments_oldest_first(): + """order="oldest" sorts the page by creation time ascending.""" + comments = [_make_comment(3), _make_comment(1), _make_comment(2)] + result = _shape_comments( + comments, detail="summary", message_max_length=None, order="oldest" ) - assert result[0].description is not None - assert result[0].description.endswith("…") + assert [c.id for c in result] == [1, 2, 3] -def test_apply_card_filters_empty_list_is_noop(): - """An empty input returns an empty output.""" - result = _apply_card_filters( - [], include_archived_cards=False, description_max_length=10 +def test_shape_comments_full_truncates_message(): + """detail="full" keeps DeckComment but truncates long messages when asked.""" + comments = [_make_comment(1, "x" * 50)] + result = _shape_comments( + comments, detail="full", message_max_length=10, order="newest" ) - assert result == [] + assert isinstance(result[0], DeckComment) + assert result[0].message.endswith("…") + assert len(result[0].message) == 11 + + +def test_truncate_comment_message_no_op_when_within_limit(): + assert _truncate_comment_message("short", 100) == "short" + assert _truncate_comment_message("short", None) == "short" # _resolve_note_path -------------------------------------------------------