From d8cd073e66f6dea4fdb8a772f042506ebc1da4f9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 3 May 2026 00:00:09 +0200 Subject: [PATCH] feat(deck): add response filters and archived stacks tool Add filtering options to deck read tools to keep responses compact on boards with accumulated cards/comments, and expose archived stacks so agents can audit completed work that has been archived off the active board. - deck_get_board: include_acl, include_users, include_labels - deck_get_stacks/deck_get_stack: include_cards, include_archived_cards, description_max_length - deck_get_cards: include_archived, description_max_length - New deck_get_archived_stacks tool wrapping the existing client method Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/server/deck.py | 146 ++++++++++++++++++++++++++-- tests/client/deck/test_deck_api.py | 31 ++++++ tests/unit/test_deck_server.py | 51 ++++++++++ 3 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_deck_server.py diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 6b7a89ba..b4dd7d3d 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -31,6 +31,17 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool logger = logging.getLogger(__name__) +def _truncate_card_descriptions( + cards: list[DeckCard], description_max_length: int | None +) -> None: + """Truncate each card's description in-place when it exceeds the limit.""" + if description_max_length is None: + return + for card in cards: + if card.description and len(card.description) > description_max_length: + card.description = card.description[:description_max_length] + "…" + + def configure_deck_tools(mcp: FastMCP): """Configure Nextcloud Deck tools and resources for the MCP server.""" @@ -143,10 +154,33 @@ def configure_deck_tools(mcp: FastMCP): ) @require_scopes("deck.read") @instrument_tool - async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: - """Get details of a specific Nextcloud Deck board""" + async def deck_get_board( + ctx: Context, + board_id: int, + include_acl: bool = True, + include_users: bool = True, + include_labels: bool = True, + ) -> DeckBoard: + """Get details of a specific Nextcloud Deck board. + + Args: + board_id: The ID of the board + include_acl: Include the board's ACL entries (default True). Set + False to reduce response size when ACLs are not needed. + include_users: Include the board's user list (default True). Set + False to reduce response size when users are not needed. + include_labels: Include the board's label definitions (default + True). Set False to reduce response size; labels can still be + retrieved via deck_get_labels. + """ client = await get_client(ctx) board = await client.deck.get_board(board_id) + if not include_acl: + board.acl = [] + if not include_users: + board.users = [] + if not include_labels: + board.labels = [] return board @mcp.tool( @@ -155,10 +189,36 @@ def configure_deck_tools(mcp: FastMCP): ) @require_scopes("deck.read") @instrument_tool - async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse: - """Get all stacks in a Nextcloud Deck board""" + async def deck_get_stacks( + ctx: Context, + board_id: int, + include_cards: bool = True, + include_archived_cards: bool = False, + description_max_length: int | None = None, + ) -> ListStacksResponse: + """Get all stacks in a Nextcloud Deck board. + + 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. + """ client = await get_client(ctx) stacks = await client.deck.get_stacks(board_id) + for stack in stacks: + if not include_cards: + stack.cards = None + continue + if 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) return ListStacksResponse(stacks=stacks, total=len(stacks)) @mcp.tool( @@ -167,12 +227,65 @@ def configure_deck_tools(mcp: FastMCP): ) @require_scopes("deck.read") @instrument_tool - async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: - """Get details of a specific Nextcloud Deck stack""" + async def deck_get_stack( + ctx: Context, + board_id: int, + stack_id: int, + include_cards: bool = True, + include_archived_cards: bool = False, + description_max_length: int | None = None, + ) -> DeckStack: + """Get details of a specific Nextcloud Deck stack. + + 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. + """ client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) + 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) return stack + @mcp.tool( + title="List Archived Deck Stacks", + annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), + ) + @require_scopes("deck.read") + @instrument_tool + async def deck_get_archived_stacks( + ctx: Context, + board_id: int, + description_max_length: int | None = None, + ) -> ListStacksResponse: + """List archived stacks (with their archived cards) for a Nextcloud + Deck board. + + Use this to audit completed work that has been archived off the + active board (e.g. cards moved through a "Done" stack and then + archived via deck_archive_card). The shape mirrors deck_get_stacks. + + Args: + board_id: The ID of the board + description_max_length: If set, truncate each card's description + to this many characters. + """ + client = await get_client(ctx) + stacks = await client.deck.get_archived_stacks(board_id) + for stack in stacks: + if stack.cards: + _truncate_card_descriptions(stack.cards, description_max_length) + return ListStacksResponse(stacks=stacks, total=len(stacks)) + @mcp.tool( title="List Deck Cards", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), @@ -180,12 +293,29 @@ def configure_deck_tools(mcp: FastMCP): @require_scopes("deck.read") @instrument_tool async def deck_get_cards( - ctx: Context, board_id: int, stack_id: int + ctx: Context, + board_id: int, + stack_id: int, + include_archived: bool = False, + description_max_length: int | None = None, ) -> ListCardsResponse: - """Get all cards in a Nextcloud Deck stack""" + """Get all cards in a Nextcloud Deck stack. + + Args: + board_id: The ID of the board + stack_id: The ID of the stack + include_archived: 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. + """ client = await get_client(ctx) stack = await client.deck.get_stack(board_id, stack_id) cards = stack.cards or [] + if not include_archived: + cards = [c for c in cards if not c.archived] + _truncate_card_descriptions(cards, description_max_length) return ListCardsResponse(cards=cards, total=len(cards)) @mcp.tool( diff --git a/tests/client/deck/test_deck_api.py b/tests/client/deck/test_deck_api.py index c5bec571..9b988e63 100644 --- a/tests/client/deck/test_deck_api.py +++ b/tests/client/deck/test_deck_api.py @@ -255,6 +255,37 @@ async def test_deck_get_stacks(mocker): mock_make_request.assert_called_once() +async def test_deck_get_archived_stacks(mocker): + """Test that get_archived_stacks targets the archived endpoint and parses the response.""" + mock_response = create_mock_response( + status_code=200, + json_data=[ + { + "id": 9, + "title": "Archived Stack", + "boardId": 123, + "order": 1, + "deletedAt": 0, + }, + ], + ) + + mock_client = mocker.AsyncMock(spec=httpx.AsyncClient) + mock_make_request = mocker.patch.object( + DeckClient, "_make_request", return_value=mock_response + ) + + client = DeckClient(mock_client, "testuser") + stacks = await client.get_archived_stacks(board_id=123) + + assert isinstance(stacks, list) + assert len(stacks) == 1 + assert stacks[0].id == 9 + + mock_make_request.assert_called_once() + assert "/boards/123/stacks/archived" in mock_make_request.call_args[0][1] + + # Card Tests diff --git a/tests/unit/test_deck_server.py b/tests/unit/test_deck_server.py new file mode 100644 index 00000000..3f48fbdf --- /dev/null +++ b/tests/unit/test_deck_server.py @@ -0,0 +1,51 @@ +import pytest + +from nextcloud_mcp_server.models.deck import DeckCard +from nextcloud_mcp_server.server.deck import _truncate_card_descriptions + +pytestmark = pytest.mark.unit + + +def _make_card(card_id: int, description: str | None) -> DeckCard: + return DeckCard( + id=card_id, + title=f"Card {card_id}", + stackId=1, + type="plain", + order=card_id, + archived=False, + owner="testuser", + description=description, + ) + + +def test_truncate_card_descriptions_no_op_when_limit_is_none(): + """When description_max_length is None, descriptions are left untouched.""" + cards = [_make_card(1, "x" * 5000)] + _truncate_card_descriptions(cards, None) + assert cards[0].description is not None + assert len(cards[0].description) == 5000 + + +def test_truncate_card_descriptions_truncates_long_descriptions(): + """Descriptions over the limit are truncated and marked with an ellipsis.""" + cards = [_make_card(1, "x" * 5000), _make_card(2, "short")] + _truncate_card_descriptions(cards, 100) + assert cards[0].description is not None + assert len(cards[0].description) == 101 # 100 chars + ellipsis + assert cards[0].description.endswith("…") + assert cards[1].description == "short" + + +def test_truncate_card_descriptions_handles_none_description(): + """Cards with no description are skipped without error.""" + cards = [_make_card(1, None)] + _truncate_card_descriptions(cards, 100) + assert cards[0].description is None + + +def test_truncate_card_descriptions_at_exact_boundary(): + """Descriptions at exactly the limit should not be truncated.""" + cards = [_make_card(1, "x" * 100)] + _truncate_card_descriptions(cards, 100) + assert cards[0].description == "x" * 100