Round-6 review: the docstrings listed preserved fields but omitted assignedUsers. Verified empirically (Deck 1.15.9) that the update route's board-change handling only remaps labels and leaves user assignments untouched, so assignees carry over. Documented in both the client and MCP tool docstrings, with the caveat that an assignee lacking access to the target board stays assigned but cannot act on the card. Added test_move_card_to_board_preserves_assigned_users to lock it in. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1782 lines
65 KiB
Python
1782 lines
65 KiB
Python
import logging
|
|
from typing import Literal, cast
|
|
|
|
import anyio
|
|
from mcp.server.fastmcp import Context, FastMCP
|
|
from mcp.types import ToolAnnotations
|
|
|
|
from nextcloud_mcp_server.auth import require_scopes
|
|
from nextcloud_mcp_server.client import NextcloudClient
|
|
from nextcloud_mcp_server.context import get_client
|
|
from nextcloud_mcp_server.models.deck import (
|
|
AttachFileResponse,
|
|
AttachmentOperationResponse,
|
|
BoardOverviewResponse,
|
|
CardCommentOperationResponse,
|
|
CardCommentResponse,
|
|
CardOperationResponse,
|
|
CreateBoardResponse,
|
|
CreateCardResponse,
|
|
CreateLabelResponse,
|
|
CreateStackResponse,
|
|
DeckAssignedUser,
|
|
DeckBoard,
|
|
DeckCard,
|
|
DeckCardSummary,
|
|
DeckComment,
|
|
DeckCommentSummary,
|
|
DeckLabel,
|
|
DeckStack,
|
|
DeckUser,
|
|
LabelOperationResponse,
|
|
ListAttachmentsResponse,
|
|
ListBoardsResponse,
|
|
ListCardCommentsResponse,
|
|
ListCardsResponse,
|
|
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_positive_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(
|
|
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:
|
|
if card.description and len(card.description) > description_max_length:
|
|
card.description = card.description[:description_max_length] + "…"
|
|
|
|
|
|
def _apply_board_filters(
|
|
board: DeckBoard,
|
|
*,
|
|
include_acl: bool,
|
|
include_users: bool,
|
|
include_labels: bool,
|
|
) -> DeckBoard:
|
|
"""Drop board sub-fields the caller didn't request; returns the board."""
|
|
if not include_acl:
|
|
board.acl = []
|
|
if not include_users:
|
|
board.users = []
|
|
if not include_labels:
|
|
board.labels = []
|
|
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.
|
|
|
|
``open``/``done``/``archived`` partition the cards (no overlap): a card
|
|
that is both done and archived is reported only under ``archived``, since
|
|
archiving is the stronger "off the active board" state.
|
|
"""
|
|
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 and not c.archived]
|
|
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,
|
|
detail: DetailLevel,
|
|
status: CardStatus,
|
|
label: str | None,
|
|
assigned_to: str | None,
|
|
description_max_length: int | None,
|
|
description_preview_length: int,
|
|
) -> DeckStack:
|
|
"""Apply card filtering + projection to a single stack; returns the stack."""
|
|
if not include_cards:
|
|
stack.cards = None
|
|
elif stack.cards:
|
|
# 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
|
|
|
|
|
|
# Statuses whose result set can contain archived cards. The active Deck
|
|
# listing endpoints (get_stacks/get_stack) exclude archived cards at the SQL
|
|
# level — only the /stacks/archived endpoint returns them — so these statuses
|
|
# need a second fetch and merge. See issue #842.
|
|
_ARCHIVED_STATUSES: frozenset[str] = frozenset({"all", "archived"})
|
|
|
|
|
|
async def _archived_cards_by_stack(
|
|
client: NextcloudClient, board_id: int
|
|
) -> dict[int, list[DeckCard]]:
|
|
"""Map stack_id -> archived DeckCards for a board.
|
|
|
|
The active stack/card listing endpoints filter out archived cards in SQL;
|
|
this hits ``/stacks/archived`` (the only endpoint that returns them) and
|
|
keys the cards by their stack so the list tools can merge them back in.
|
|
"""
|
|
archived_stacks = await client.deck.get_archived_stacks(board_id)
|
|
return {
|
|
stack.id: cast(list[DeckCard], stack.cards or []) for stack in archived_stacks
|
|
}
|
|
|
|
|
|
def _append_archived_cards(stack: DeckStack, extra: list[DeckCard]) -> None:
|
|
"""Append archived cards onto a stack's existing card list, in place.
|
|
|
|
Kept separate so the assignment stays correctly typed against
|
|
``DeckStack.cards`` (``list[DeckCard | DeckCardSummary] | None``).
|
|
"""
|
|
merged: list[DeckCard | DeckCardSummary] = list(stack.cards or [])
|
|
merged.extend(extra)
|
|
stack.cards = merged
|
|
|
|
|
|
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],
|
|
*,
|
|
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).
|
|
#
|
|
# Mechanism: a Deck card attachment of type="file" is just a Nextcloud share
|
|
# with shareType=12 (IShare::TYPE_DECK) and shareWith=<cardId>. The Deck UI
|
|
# fires this exact request — see Deck app's
|
|
# src/components/card/AttachmentList.vue:223-238 and lib/Service/FilesAppService.php.
|
|
# The file is NOT copied; the share row binds the file's existing path to the card.
|
|
_SHARE_TYPE_DECK = 12
|
|
|
|
|
|
def _resolve_note_path(notes_folder: str, category: str, title: str) -> str:
|
|
"""Reconstruct a note's file path from Notes API metadata.
|
|
|
|
Notes are stored as ``<notes_folder>/<category>/<title>.md`` in the
|
|
user's Files; ``<category>`` may be empty or nested (``"Foo/Bar"``).
|
|
"""
|
|
parts = [notes_folder.strip("/")]
|
|
if category:
|
|
parts.append(category.strip("/"))
|
|
parts.append(f"{title}.md")
|
|
return "/" + "/".join(p for p in parts if p)
|
|
|
|
|
|
async def _resolve_note_attach_path(client, note_id: int) -> str:
|
|
"""Resolve a Notes-app note ID to its filesystem path for sharing.
|
|
|
|
Hits the Notes API twice (settings + note metadata) and reconstructs
|
|
the path. Encapsulates the camelCase key (``notesPath``, see
|
|
``models/notes.py:43``) so a typo there can't silently route to the
|
|
default ``"Notes"`` folder for users who've configured a non-default
|
|
notes location — that bug is exactly what this helper exists to make
|
|
testable.
|
|
"""
|
|
async with anyio.create_task_group() as tg:
|
|
settings_holder: list[dict] = []
|
|
note_holder: list[dict] = []
|
|
|
|
async def _get_settings() -> None:
|
|
settings_holder.append(await client.notes.get_settings())
|
|
|
|
async def _get_note() -> None:
|
|
note_holder.append(await client.notes.get_note(note_id))
|
|
|
|
tg.start_soon(_get_settings)
|
|
tg.start_soon(_get_note)
|
|
|
|
settings = settings_holder[0]
|
|
note = note_holder[0]
|
|
notes_folder = settings.get("notesPath") or "Notes"
|
|
return _resolve_note_path(
|
|
notes_folder=notes_folder,
|
|
category=note.get("category") or "",
|
|
title=note["title"],
|
|
)
|
|
|
|
|
|
def configure_deck_tools(mcp: FastMCP):
|
|
"""Configure Nextcloud Deck tools and resources for the MCP server."""
|
|
|
|
# Resources
|
|
@mcp.resource("nc://Deck/boards")
|
|
async def deck_boards_resource():
|
|
"""List all Nextcloud Deck boards"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
|
client = await get_client(ctx)
|
|
boards = await client.deck.get_boards()
|
|
return [board.model_dump() for board in boards]
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}")
|
|
async def deck_board_resource(board_id: int):
|
|
"""Get details of a specific Nextcloud Deck board"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_board tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
board = await client.deck.get_board(board_id)
|
|
return board.model_dump()
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/stacks")
|
|
async def deck_stacks_resource(board_id: int):
|
|
"""List all stacks in a Nextcloud Deck board"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_stacks tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
stacks = await client.deck.get_stacks(board_id)
|
|
return [stack.model_dump() for stack in stacks]
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}")
|
|
async def deck_stack_resource(board_id: int, stack_id: int):
|
|
"""Get details of a specific Nextcloud Deck stack"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_stack tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
stack = await client.deck.get_stack(board_id, stack_id)
|
|
return stack.model_dump()
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards")
|
|
async def deck_cards_resource(board_id: int, stack_id: int):
|
|
"""List all cards in a Nextcloud Deck stack"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_cards tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
stack = await client.deck.get_stack(board_id, stack_id)
|
|
if stack.cards:
|
|
return [card.model_dump() for card in stack.cards]
|
|
return []
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}")
|
|
async def deck_card_resource(board_id: int, stack_id: int, card_id: int):
|
|
"""Get details of a specific Nextcloud Deck card"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_card tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
|
return card.model_dump()
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/labels")
|
|
async def deck_labels_resource(board_id: int):
|
|
"""List all labels in a Nextcloud Deck board"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_labels tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
board = await client.deck.get_board(board_id)
|
|
return [label.model_dump() for label in (board.labels or [])]
|
|
|
|
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
|
async def deck_label_resource(board_id: int, label_id: int):
|
|
"""Get details of a specific Nextcloud Deck label"""
|
|
ctx: Context = mcp.get_context()
|
|
await ctx.warning(
|
|
"This resource is deprecated, use the deck_get_label tool instead"
|
|
)
|
|
client = await get_client(ctx)
|
|
label = await client.deck.get_label(board_id, label_id)
|
|
return label.model_dump()
|
|
|
|
# Read Tools (converted from resources)
|
|
|
|
@mcp.tool(
|
|
title="List Deck Boards",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
|
"""Get all Nextcloud Deck boards"""
|
|
client = await get_client(ctx)
|
|
boards = await client.deck.get_boards()
|
|
return ListBoardsResponse(boards=boards, total=len(boards))
|
|
|
|
@mcp.tool(
|
|
title="Get Deck Board",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
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)
|
|
return _apply_board_filters(
|
|
board,
|
|
include_acl=include_acl,
|
|
include_users=include_users,
|
|
include_labels=include_labels,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="List Deck Stacks",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_stacks(
|
|
ctx: Context,
|
|
board_id: int,
|
|
include_cards: bool = True,
|
|
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.
|
|
detail: "summary" (default) returns compact card rows; "full"
|
|
returns the complete card objects (the old behavior).
|
|
status: Which cards to include — "open" (default), "done",
|
|
"archived", or "all". The first three partition the board
|
|
(a card that is both done and archived counts as "archived").
|
|
"archived"/"all" include archived cards, which the active
|
|
listing endpoint omits — this costs one extra API call.
|
|
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_positive_length(description_max_length)
|
|
_validate_positive_length(
|
|
description_preview_length, "description_preview_length"
|
|
)
|
|
client = await get_client(ctx)
|
|
|
|
# Fetch active stacks and (when archived cards are in scope) the
|
|
# archived endpoint concurrently, then merge archived cards onto each
|
|
# stack by id before filtering. The active endpoint omits archived
|
|
# cards, so without this merge status="archived"/"all" would drop them.
|
|
stacks_holder: list[list[DeckStack]] = []
|
|
archived_by_stack: dict[int, list[DeckCard]] = {}
|
|
merge_archived = include_cards and status in _ARCHIVED_STATUSES
|
|
|
|
async def _get_active() -> None:
|
|
stacks_holder.append(await client.deck.get_stacks(board_id))
|
|
|
|
async def _get_archived() -> None:
|
|
archived_by_stack.update(await _archived_cards_by_stack(client, board_id))
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(_get_active)
|
|
if merge_archived:
|
|
tg.start_soon(_get_archived)
|
|
|
|
stacks = stacks_holder[0]
|
|
if merge_archived:
|
|
for stack in stacks:
|
|
extra = archived_by_stack.get(stack.id)
|
|
if extra:
|
|
_append_archived_cards(stack, extra)
|
|
|
|
stacks = [
|
|
_apply_stack_filters(
|
|
stack,
|
|
include_cards=include_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
|
|
]
|
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
|
|
|
@mcp.tool(
|
|
title="Get Deck Stack",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_stack(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
include_cards: bool = True,
|
|
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).
|
|
detail: "summary" (default) or "full".
|
|
status: "open" (default), "done", "archived", or "all"
|
|
(non-overlapping; a done+archived card counts as "archived").
|
|
"archived"/"all" include archived cards, which the active
|
|
listing endpoint omits — this costs one extra API call.
|
|
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_positive_length(description_max_length)
|
|
_validate_positive_length(
|
|
description_preview_length, "description_preview_length"
|
|
)
|
|
client = await get_client(ctx)
|
|
if status == "archived" and include_cards:
|
|
# Archived-only: the /stacks/archived endpoint already returns the
|
|
# stack (metadata + archived cards) in one call, so skip the active
|
|
# fetch whose open cards would all be filtered out anyway.
|
|
archived = await client.deck.get_archived_stacks(board_id)
|
|
stack = next((s for s in archived if s.id == stack_id), None)
|
|
if stack is None:
|
|
# findAllArchived returns every stack, so this is defensive;
|
|
# fall back to the active endpoint for the stack metadata.
|
|
stack = await client.deck.get_stack(board_id, stack_id)
|
|
else:
|
|
# Active stack always needed (for metadata + open cards); fetch the
|
|
# archived cards concurrently when status="all" needs both sets.
|
|
stack_holder: list[DeckStack] = []
|
|
archived_by_stack: dict[int, list[DeckCard]] = {}
|
|
merge_archived = include_cards and status == "all"
|
|
|
|
async def _get_active() -> None:
|
|
stack_holder.append(await client.deck.get_stack(board_id, stack_id))
|
|
|
|
async def _get_archived() -> None:
|
|
archived_by_stack.update(
|
|
await _archived_cards_by_stack(client, board_id)
|
|
)
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(_get_active)
|
|
if merge_archived:
|
|
tg.start_soon(_get_archived)
|
|
|
|
stack = stack_holder[0]
|
|
extra = archived_by_stack.get(stack_id)
|
|
if extra:
|
|
_append_archived_cards(stack, extra)
|
|
return _apply_stack_filters(
|
|
stack,
|
|
include_cards=include_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(
|
|
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,
|
|
detail: DetailLevel = "summary",
|
|
label: str | None = None,
|
|
assigned_to: str | None = None,
|
|
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.
|
|
|
|
This is the archived-only shortcut: it returns *only* archived cards
|
|
in a single call. The active list tools (deck_get_cards,
|
|
deck_get_stacks, deck_get_board_overview) also include archived cards
|
|
when called with status="archived"/"all"; use this tool when you want
|
|
archived cards exclusively and don't need the open ones. Typical use:
|
|
auditing completed work 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.
|
|
|
|
Cards are always included on the returned stacks (an archived stack
|
|
without its cards would have no audit value) and returned as compact
|
|
summaries by default. There is no ``status`` filter — every card here
|
|
is archived by definition — but ``label``/``assigned_to`` narrow the
|
|
set just like the active-stack tools.
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
detail: "summary" (default) or "full".
|
|
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_positive_length(description_max_length)
|
|
_validate_positive_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; status="all"
|
|
# keeps them (an "open"/"done" filter would drop the whole point).
|
|
# label/assigned_to still apply for targeted audits.
|
|
stacks = [
|
|
_apply_stack_filters(
|
|
stack,
|
|
include_cards=True,
|
|
detail=detail,
|
|
status="all",
|
|
label=label,
|
|
assigned_to=assigned_to,
|
|
description_max_length=description_max_length,
|
|
description_preview_length=description_preview_length,
|
|
)
|
|
for stack in stacks
|
|
]
|
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
|
|
|
@mcp.tool(
|
|
title="List Deck Cards",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_cards(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
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.
|
|
|
|
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
|
|
detail: "summary" (default) returns compact card rows; "full"
|
|
returns the complete card objects.
|
|
status: "open" (default), "done", "archived", or "all". The first
|
|
three partition the board (a done+archived card counts as
|
|
"archived"). "archived"/"all" include archived cards, which the
|
|
active listing endpoint omits — this costs one extra API call.
|
|
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_positive_length(description_max_length)
|
|
_validate_positive_length(
|
|
description_preview_length, "description_preview_length"
|
|
)
|
|
client = await get_client(ctx)
|
|
|
|
# Archived cards are excluded by the active stack endpoint, so for
|
|
# statuses that can include them we also fetch /stacks/archived and
|
|
# merge. "open"/"done" need only the active stack (no extra call).
|
|
active_cards: list[DeckCard] = []
|
|
archived_cards: list[DeckCard] = []
|
|
need_active = status != "archived"
|
|
need_archived = status in _ARCHIVED_STATUSES
|
|
|
|
async def _get_active() -> None:
|
|
stack = await client.deck.get_stack(board_id, stack_id)
|
|
active_cards.extend(cast(list[DeckCard], stack.cards or []))
|
|
|
|
async def _get_archived() -> None:
|
|
by_stack = await _archived_cards_by_stack(client, board_id)
|
|
archived_cards.extend(by_stack.get(stack_id, []))
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
if need_active:
|
|
tg.start_soon(_get_active)
|
|
if need_archived:
|
|
tg.start_soon(_get_archived)
|
|
|
|
cards = _shape_cards(
|
|
active_cards + archived_cards,
|
|
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. Prefer it for "show me the
|
|
board" / "what's in progress" style requests on large boards — it is
|
|
the token-efficient way to view board *state*. It intentionally omits
|
|
the board-management fields (ACL, user list, full label objects) that
|
|
deck_get_board exposes; reach for deck_get_board when you need those.
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
status: Which cards to include — "open" (default), "done",
|
|
"archived", or "all". The first three partition the board
|
|
(a card that is both done and archived counts as "archived").
|
|
"archived"/"all" include archived cards, which the active
|
|
listing endpoint omits — this costs one extra API call.
|
|
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_positive_length(
|
|
description_preview_length, "description_preview_length"
|
|
)
|
|
client = await get_client(ctx)
|
|
|
|
board_holder: list[DeckBoard] = []
|
|
stacks_holder: list[list[DeckStack]] = []
|
|
archived_by_stack: dict[int, list[DeckCard]] = {}
|
|
merge_archived = status in _ARCHIVED_STATUSES
|
|
|
|
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 def _get_archived() -> None:
|
|
archived_by_stack.update(await _archived_cards_by_stack(client, board_id))
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(_get_board)
|
|
tg.start_soon(_get_stacks)
|
|
if merge_archived:
|
|
tg.start_soon(_get_archived)
|
|
|
|
board = board_holder[0]
|
|
stacks = stacks_holder[0]
|
|
|
|
stack_overviews: list[StackOverview] = []
|
|
total_cards = 0
|
|
for stack in stacks:
|
|
cards = cast(list[DeckCard], stack.cards or [])
|
|
if merge_archived:
|
|
cards = cards + archived_by_stack.get(stack.id, [])
|
|
summaries = [
|
|
_summarize_card(c, description_preview_length)
|
|
for c in _filter_cards(
|
|
cards,
|
|
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),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
|
) -> DeckCard:
|
|
"""Get details of a specific Nextcloud Deck card"""
|
|
client = await get_client(ctx)
|
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
|
return card
|
|
|
|
@mcp.tool(
|
|
title="List Deck Labels",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
|
"""Get all labels in a Nextcloud Deck board"""
|
|
client = await get_client(ctx)
|
|
board = await client.deck.get_board(board_id)
|
|
labels = board.labels or []
|
|
return ListLabelsResponse(labels=labels, total=len(labels))
|
|
|
|
@mcp.tool(
|
|
title="Get Deck Label",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
|
"""Get details of a specific Nextcloud Deck label"""
|
|
client = await get_client(ctx)
|
|
label = await client.deck.get_label(board_id, label_id)
|
|
return label
|
|
|
|
# Create/Update/Delete Tools
|
|
|
|
@mcp.tool(
|
|
title="Create Deck Board",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_create_board(
|
|
ctx: Context, title: str, color: str
|
|
) -> CreateBoardResponse:
|
|
"""Create a new Nextcloud Deck board
|
|
|
|
Args:
|
|
title: The title of the new board
|
|
color: The hexadecimal color of the new board (e.g. FF0000)
|
|
"""
|
|
client = await get_client(ctx)
|
|
board = await client.deck.create_board(title, color)
|
|
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
|
|
|
# Stack Tools
|
|
|
|
@mcp.tool(
|
|
title="Create Deck Stack",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_create_stack(
|
|
ctx: Context, board_id: int, title: str, order: int
|
|
) -> CreateStackResponse:
|
|
"""Create a new stack in a Nextcloud Deck board
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
title: The title of the new stack
|
|
order: Order for sorting the stacks
|
|
"""
|
|
client = await get_client(ctx)
|
|
stack = await client.deck.create_stack(board_id, title, order)
|
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
|
|
|
@mcp.tool(
|
|
title="Update Deck Stack",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_update_stack(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
title: str | None = None,
|
|
order: int | None = None,
|
|
) -> StackOperationResponse:
|
|
"""Update a Nextcloud Deck stack
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
title: New title for the stack
|
|
order: New order for the stack
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.update_stack(board_id, stack_id, title, order)
|
|
return StackOperationResponse(
|
|
success=True,
|
|
message="Stack updated successfully",
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Delete Deck Stack",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_delete_stack(
|
|
ctx: Context, board_id: int, stack_id: int
|
|
) -> StackOperationResponse:
|
|
"""Delete a Nextcloud Deck stack
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.delete_stack(board_id, stack_id)
|
|
return StackOperationResponse(
|
|
success=True,
|
|
message="Stack deleted successfully",
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
# Card Tools
|
|
@mcp.tool(
|
|
title="Create Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_create_card(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
title: str,
|
|
type: str = "plain",
|
|
order: int = 999,
|
|
description: str | None = None,
|
|
duedate: str | None = None,
|
|
) -> CreateCardResponse:
|
|
"""Create a new card in a Nextcloud Deck stack
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
title: The title of the new card
|
|
type: Type of the card (default: plain)
|
|
order: Order for sorting the cards
|
|
description: Description of the card
|
|
duedate: Due date of the card (ISO-8601 format)
|
|
"""
|
|
client = await get_client(ctx)
|
|
card = await client.deck.create_card(
|
|
board_id, stack_id, title, type, order, description, duedate
|
|
)
|
|
return CreateCardResponse(
|
|
id=card.id,
|
|
title=card.title,
|
|
description=card.description,
|
|
stackId=card.stackId,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Update Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_update_card(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
card_id: int,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
type: str | None = None,
|
|
owner: str | None = None,
|
|
order: int | None = None,
|
|
duedate: str | None = None,
|
|
archived: bool | None = None,
|
|
done: str | None = None,
|
|
) -> CardOperationResponse:
|
|
"""Update a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
title: New title for the card
|
|
description: New description for the card
|
|
type: New type for the card
|
|
owner: New owner for the card
|
|
order: New order for the card
|
|
duedate: New due date for the card (ISO-8601 format)
|
|
archived: Whether the card should be archived
|
|
done: Completion date for the card (ISO-8601 format)
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.update_card(
|
|
board_id,
|
|
stack_id,
|
|
card_id,
|
|
title,
|
|
description,
|
|
type,
|
|
owner,
|
|
order,
|
|
duedate,
|
|
archived,
|
|
done,
|
|
)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card updated successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Delete Deck Card",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_delete_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
|
) -> CardOperationResponse:
|
|
"""Delete a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.delete_card(board_id, stack_id, card_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card deleted successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Archive Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_archive_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
|
) -> CardOperationResponse:
|
|
"""Archive a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.archive_card(board_id, stack_id, card_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card archived successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Unarchive Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_unarchive_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
|
) -> CardOperationResponse:
|
|
"""Unarchive a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card unarchived successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Reorder Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_reorder_card(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
card_id: int,
|
|
order: int,
|
|
target_stack_id: int,
|
|
) -> CardOperationResponse:
|
|
"""Reorder a Nextcloud Deck card within a board.
|
|
|
|
Moves a card to a new position, optionally into a different stack on
|
|
the SAME board. To move a card to a stack on a DIFFERENT board, use
|
|
deck_move_card_to_board instead — reordering across boards is rejected
|
|
because it would orphan the card's board-scoped labels.
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the current stack
|
|
card_id: The ID of the card
|
|
order: New position in the target stack
|
|
target_stack_id: The ID of the target stack (must be on board_id)
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.reorder_card(
|
|
board_id, stack_id, card_id, order, target_stack_id
|
|
)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card reordered successfully",
|
|
card_id=card_id,
|
|
stack_id=target_stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Move Deck Card to Another Board",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_move_card_to_board(
|
|
ctx: Context,
|
|
source_board_id: int,
|
|
source_stack_id: int,
|
|
card_id: int,
|
|
target_board_id: int,
|
|
target_stack_id: int,
|
|
order: int = 0,
|
|
) -> CardOperationResponse:
|
|
"""Move a Nextcloud Deck card to a stack on a different board.
|
|
|
|
The card keeps its identity (same id, comments, attachments), along
|
|
with its archived state, due date and user assignments (an assignee
|
|
without access to the target board stays assigned but cannot act on the
|
|
card). Deck remaps the card's board-scoped labels to the destination
|
|
board by title — reusing a same-titled label there, or cloning it when
|
|
you have board-manage permission. Use deck_reorder_card for moves
|
|
within a single board.
|
|
|
|
Two caveats from Deck's move route: the card's owner is reassigned to
|
|
the user performing the move (the original owner is not preserved), and
|
|
a card marked done keeps its done state but its done timestamp is reset
|
|
to the time of the move.
|
|
|
|
target_stack_id must be a stack on target_board_id; the move is
|
|
rejected otherwise.
|
|
|
|
Args:
|
|
source_board_id: The ID of the board the card currently lives on
|
|
source_stack_id: The ID of the stack the card currently lives in
|
|
card_id: The ID of the card to move
|
|
target_board_id: The ID of the destination board
|
|
target_stack_id: The ID of the destination stack (must be on target_board_id)
|
|
order: Position within the destination stack (default 0 = top)
|
|
"""
|
|
client = await get_client(ctx)
|
|
moved = await client.deck.move_card_to_board(
|
|
source_board_id,
|
|
source_stack_id,
|
|
card_id,
|
|
target_board_id,
|
|
target_stack_id,
|
|
order,
|
|
)
|
|
# Surface the post-move labels so callers can confirm the remap without
|
|
# a follow-up get_card (label remapping is this tool's whole point).
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Card moved to board successfully",
|
|
card_id=card_id,
|
|
stack_id=target_stack_id,
|
|
board_id=target_board_id,
|
|
labels=[label.title for label in (moved.labels or [])],
|
|
)
|
|
|
|
# Label Tools
|
|
@mcp.tool(
|
|
title="Create Deck Label",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_create_label(
|
|
ctx: Context, board_id: int, title: str, color: str
|
|
) -> CreateLabelResponse:
|
|
"""Create a new label in a Nextcloud Deck board
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
title: The title of the new label
|
|
color: The color of the new label (hex format without #)
|
|
"""
|
|
client = await get_client(ctx)
|
|
label = await client.deck.create_label(board_id, title, color)
|
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
|
|
|
@mcp.tool(
|
|
title="Update Deck Label",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_update_label(
|
|
ctx: Context,
|
|
board_id: int,
|
|
label_id: int,
|
|
title: str | None = None,
|
|
color: str | None = None,
|
|
) -> LabelOperationResponse:
|
|
"""Update a Nextcloud Deck label
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
label_id: The ID of the label
|
|
title: New title for the label
|
|
color: New color for the label (hex format without #)
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.update_label(board_id, label_id, title, color)
|
|
return LabelOperationResponse(
|
|
success=True,
|
|
message="Label updated successfully",
|
|
label_id=label_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Delete Deck Label",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_delete_label(
|
|
ctx: Context, board_id: int, label_id: int
|
|
) -> LabelOperationResponse:
|
|
"""Delete a Nextcloud Deck label
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
label_id: The ID of the label
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.delete_label(board_id, label_id)
|
|
return LabelOperationResponse(
|
|
success=True,
|
|
message="Label deleted successfully",
|
|
label_id=label_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
# Card-Label Assignment Tools
|
|
@mcp.tool(
|
|
title="Assign Label to Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_assign_label_to_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
|
) -> CardOperationResponse:
|
|
"""Assign a label to a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
label_id: The ID of the label to assign
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Label assigned to card successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Remove Label from Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_remove_label_from_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
|
) -> CardOperationResponse:
|
|
"""Remove a label from a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
label_id: The ID of the label to remove
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="Label removed from card successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
# Card-User Assignment Tools
|
|
@mcp.tool(
|
|
title="Assign User to Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_assign_user_to_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
|
) -> CardOperationResponse:
|
|
"""Assign a user to a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
user_id: The user ID to assign
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="User assigned to card successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Unassign User from Deck Card",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_unassign_user_from_card(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
|
) -> CardOperationResponse:
|
|
"""Unassign a user from a Nextcloud Deck card
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
user_id: The user ID to unassign
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
|
return CardOperationResponse(
|
|
success=True,
|
|
message="User unassigned from card successfully",
|
|
card_id=card_id,
|
|
stack_id=stack_id,
|
|
board_id=board_id,
|
|
)
|
|
|
|
# Card Comment Tools
|
|
|
|
_COMMENT_MAX_LENGTH = 1000
|
|
|
|
def _validate_comment_message(message: str) -> None:
|
|
if len(message) > _COMMENT_MAX_LENGTH:
|
|
raise ValueError(
|
|
f"Comment message too long: {len(message)} characters "
|
|
f"(max {_COMMENT_MAX_LENGTH})"
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="List Deck Card Comments",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_get_card_comments(
|
|
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.
|
|
|
|
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_positive_length(message_max_length, "message_max_length")
|
|
client = await get_client(ctx)
|
|
comments = await client.deck.get_comments(card_id, limit=limit, offset=offset)
|
|
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",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_create_card_comment(
|
|
ctx: Context,
|
|
card_id: int,
|
|
message: str,
|
|
parent_id: int | None = None,
|
|
) -> CardCommentResponse:
|
|
"""Create a comment on a Nextcloud Deck card
|
|
|
|
Supports @-mentions (e.g. "@alice"). Pass parent_id to reply to an
|
|
existing comment on the same card. Message is limited to 1000 characters.
|
|
|
|
Args:
|
|
card_id: The ID of the card to comment on
|
|
message: The comment text (max 1000 characters)
|
|
parent_id: Optional ID of a parent comment to reply to
|
|
"""
|
|
_validate_comment_message(message)
|
|
client = await get_client(ctx)
|
|
comment = await client.deck.create_comment(
|
|
card_id, message, parent_id=parent_id
|
|
)
|
|
return CardCommentResponse(comment=comment)
|
|
|
|
@mcp.tool(
|
|
title="Update Deck Card Comment",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_update_card_comment(
|
|
ctx: Context, card_id: int, comment_id: int, message: str
|
|
) -> CardCommentResponse:
|
|
"""Update a Nextcloud Deck card comment
|
|
|
|
Only the comment's author can update it; the server returns 403 otherwise.
|
|
|
|
Args:
|
|
card_id: The ID of the card the comment belongs to
|
|
comment_id: The ID of the comment to update
|
|
message: The new comment text (max 1000 characters)
|
|
"""
|
|
_validate_comment_message(message)
|
|
client = await get_client(ctx)
|
|
comment = await client.deck.update_comment(card_id, comment_id, message)
|
|
return CardCommentResponse(comment=comment)
|
|
|
|
@mcp.tool(
|
|
title="Delete Deck Card Comment",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_delete_card_comment(
|
|
ctx: Context, card_id: int, comment_id: int
|
|
) -> CardCommentOperationResponse:
|
|
"""Delete a Nextcloud Deck card comment
|
|
|
|
Only the comment's author can delete it; the server returns 403 otherwise.
|
|
|
|
Args:
|
|
card_id: The ID of the card the comment belongs to
|
|
comment_id: The ID of the comment to delete
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.delete_comment(card_id, comment_id)
|
|
return CardCommentOperationResponse(
|
|
success=True,
|
|
message="Comment deleted successfully",
|
|
card_id=card_id,
|
|
comment_id=comment_id,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Attach File to Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write", "files.read")
|
|
@instrument_tool
|
|
async def deck_attach_file(
|
|
ctx: Context, card_id: int, path: str
|
|
) -> AttachFileResponse:
|
|
"""Attach an existing Nextcloud file to a Deck card without copying.
|
|
|
|
Creates a share of ``path`` with the card (``shareType=12``,
|
|
``shareWith=<card_id>``). The file stays in its original location;
|
|
clicking the attachment in the Deck UI opens the file in place.
|
|
|
|
Generic over the user's Files: works for any file the caller can
|
|
read — markdown notes, PDFs, images, spreadsheets, etc. Use
|
|
:func:`deck_attach_note` if you have a Notes-app note ID and want
|
|
the path resolved automatically. Calling twice with the same
|
|
``path`` creates two distinct shares — caller is responsible for
|
|
de-duping.
|
|
|
|
Args:
|
|
card_id: The ID of the Deck card to attach to
|
|
path: Path to the file in the user's Nextcloud Files (must start
|
|
with "/", e.g. "/Documents/spec.pdf" or "/Notes/My Note.md")
|
|
"""
|
|
if not path.startswith("/"):
|
|
raise ValueError(
|
|
f"path must start with '/', got: {path!r} "
|
|
"(paths are relative to the user's Files root)"
|
|
)
|
|
client = await get_client(ctx)
|
|
share = await client.sharing.create_share(
|
|
path=path,
|
|
share_with=str(card_id),
|
|
share_type=_SHARE_TYPE_DECK,
|
|
permissions=1,
|
|
)
|
|
return AttachFileResponse(
|
|
attachment_id=int(share["id"]),
|
|
card_id=card_id,
|
|
path=path,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="Attach Note to Deck Card",
|
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.write", "files.read", "notes.read")
|
|
@instrument_tool
|
|
async def deck_attach_note(
|
|
ctx: Context, card_id: int, note_id: int
|
|
) -> AttachFileResponse:
|
|
"""Attach a Nextcloud Note to a Deck card without copying.
|
|
|
|
Convenience wrapper: looks up the note's filesystem path from the
|
|
Notes app settings + note metadata, then shares the file with the
|
|
card (same mechanism as :func:`deck_attach_file`). The note remains
|
|
editable in the Notes app; the card just shows a clickable link to
|
|
it.
|
|
|
|
Path is reconstructed as ``<notes_folder>/<category>/<title>.md``.
|
|
If the note's title contains characters that the Notes app sanitises
|
|
differently (rare), use :func:`deck_attach_file` with the explicit
|
|
path instead.
|
|
|
|
Args:
|
|
card_id: The ID of the Deck card to attach to
|
|
note_id: The ID of the Note to attach
|
|
"""
|
|
client = await get_client(ctx)
|
|
path = await _resolve_note_attach_path(client, note_id)
|
|
share = await client.sharing.create_share(
|
|
path=path,
|
|
share_with=str(card_id),
|
|
share_type=_SHARE_TYPE_DECK,
|
|
permissions=1,
|
|
)
|
|
return AttachFileResponse(
|
|
attachment_id=int(share["id"]),
|
|
card_id=card_id,
|
|
path=path,
|
|
)
|
|
|
|
@mcp.tool(
|
|
title="List Deck Card Attachments",
|
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
|
)
|
|
@require_scopes("deck.read")
|
|
@instrument_tool
|
|
async def deck_list_attachments(
|
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
|
) -> ListAttachmentsResponse:
|
|
"""List attachments on a Nextcloud Deck card.
|
|
|
|
Returns both shared-file attachments (``type="file"``, created via
|
|
:func:`deck_attach_file` / :func:`deck_attach_note`) and uploaded
|
|
binary attachments (``type="deck_file"``).
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
"""
|
|
client = await get_client(ctx)
|
|
attachments = await client.deck.get_attachments(board_id, stack_id, card_id)
|
|
return ListAttachmentsResponse(results=attachments, count=len(attachments))
|
|
|
|
@mcp.tool(
|
|
title="Delete Deck Card Attachment",
|
|
annotations=ToolAnnotations(
|
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
|
),
|
|
)
|
|
@require_scopes("deck.write")
|
|
@instrument_tool
|
|
async def deck_delete_attachment(
|
|
ctx: Context,
|
|
board_id: int,
|
|
stack_id: int,
|
|
card_id: int,
|
|
attachment_id: int,
|
|
) -> AttachmentOperationResponse:
|
|
"""Delete an attachment from a Nextcloud Deck card.
|
|
|
|
For ``type="file"`` attachments this removes the share linking the
|
|
file to the card; the underlying file in the user's Files is left
|
|
untouched. For ``type="deck_file"`` blobs the binary is deleted from
|
|
Deck's storage.
|
|
|
|
Args:
|
|
board_id: The ID of the board
|
|
stack_id: The ID of the stack
|
|
card_id: The ID of the card
|
|
attachment_id: The ID of the attachment to delete
|
|
"""
|
|
client = await get_client(ctx)
|
|
await client.deck.delete_attachment(board_id, stack_id, card_id, attachment_id)
|
|
return AttachmentOperationResponse(
|
|
success=True,
|
|
message="Attachment deleted successfully",
|
|
card_id=card_id,
|
|
attachment_id=attachment_id,
|
|
)
|