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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
41d2286aab
commit
d8cd073e66
@@ -31,6 +31,17 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
def configure_deck_tools(mcp: FastMCP):
|
||||||
"""Configure Nextcloud Deck tools and resources for the MCP server."""
|
"""Configure Nextcloud Deck tools and resources for the MCP server."""
|
||||||
|
|
||||||
@@ -143,10 +154,33 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck.read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(
|
||||||
"""Get details of a specific Nextcloud Deck 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)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
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
|
return board
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -155,10 +189,36 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck.read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
async def deck_get_stacks(
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
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)
|
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:
|
||||||
|
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))
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -167,12 +227,65 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck.read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(
|
||||||
"""Get details of a specific Nextcloud Deck 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)
|
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)
|
||||||
|
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
|
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(
|
@mcp.tool(
|
||||||
title="List Deck Cards",
|
title="List Deck Cards",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
@@ -180,12 +293,29 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@require_scopes("deck.read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
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:
|
) -> 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)
|
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 = stack.cards or []
|
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))
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
|
|||||||
@@ -255,6 +255,37 @@ async def test_deck_get_stacks(mocker):
|
|||||||
mock_make_request.assert_called_once()
|
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
|
# Card Tests
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user