From 167053578dce7024f49d6234b475e35ad30c0df6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 00:10:25 +0200 Subject: [PATCH 1/4] feat(deck): Initialize Deck app client/server --- .../post-installation/install-deck-app.sh | 3 + docker-compose.yml | 6 +- nextcloud_mcp_server/app.py | 4 +- nextcloud_mcp_server/client/__init__.py | 2 + nextcloud_mcp_server/client/deck.py | 602 ++++++++++++++++++ nextcloud_mcp_server/models/deck.py | 183 ++++++ nextcloud_mcp_server/server/__init__.py | 4 +- nextcloud_mcp_server/server/deck.py | 76 +++ tests/conftest.py | 146 +++++ tests/integration/test_deck_api.py | 327 ++++++++++ tests/integration/test_deck_mcp.py | 268 ++++++++ tests/integration/test_mcp.py | 5 +- 12 files changed, 1620 insertions(+), 6 deletions(-) create mode 100755 app-hooks/post-installation/install-deck-app.sh create mode 100644 nextcloud_mcp_server/client/deck.py create mode 100644 nextcloud_mcp_server/models/deck.py create mode 100644 nextcloud_mcp_server/server/deck.py create mode 100644 tests/integration/test_deck_api.py create mode 100644 tests/integration/test_deck_mcp.py diff --git a/app-hooks/post-installation/install-deck-app.sh b/app-hooks/post-installation/install-deck-app.sh new file mode 100755 index 0000000..8594e3b --- /dev/null +++ b/app-hooks/post-installation/install-deck-app.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +php /var/www/html/occ app:enable deck diff --git a/docker-compose.yml b/docker-compose.yml index 4475b8a..caa7204 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,15 +46,15 @@ services: mcp: build: . - command: ["--host", "0.0.0.0", "--log-level", "debug"] + command: ["--host", "0.0.0.0"] ports: - 8000:8000 environment: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_USERNAME=admin - NEXTCLOUD_PASSWORD=admin - volumes: - - ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro + #volumes: + #- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro volumes: nextcloud: diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index c8a8418..c75c752 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -18,6 +18,7 @@ from nextcloud_mcp_server.server import ( configure_notes_tools, configure_tables_tools, configure_webdav_tools, + configure_deck_tools, ) @@ -65,6 +66,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "webdav": configure_webdav_tools, "calendar": configure_calendar_tools, "contacts": configure_contacts_tools, + "deck": configure_deck_tools, } # If no specific apps are specified, enable all @@ -104,7 +106,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): "--enable-app", "-e", multiple=True, - type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts"]), + type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]), help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.", ) def run( diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 86be374..b6879c6 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -14,6 +14,7 @@ from httpx import ( from ..controllers.notes_search import NotesSearchController from .calendar import CalendarClient from .contacts import ContactsClient +from .deck import DeckClient from .notes import NotesClient from .tables import TablesClient from .webdav import WebDAVClient @@ -69,6 +70,7 @@ class NextcloudClient: self.tables = TablesClient(self._client, username) self.calendar = CalendarClient(self._client, username) self.contacts = ContactsClient(self._client, username) + self.deck = DeckClient(self._client, username) # Initialize controllers self._notes_search = NotesSearchController() diff --git a/nextcloud_mcp_server/client/deck.py b/nextcloud_mcp_server/client/deck.py new file mode 100644 index 0000000..eab85b2 --- /dev/null +++ b/nextcloud_mcp_server/client/deck.py @@ -0,0 +1,602 @@ +from typing import List, Optional, Dict, Any + +from nextcloud_mcp_server.client.base import BaseNextcloudClient +from nextcloud_mcp_server.models.deck import ( + DeckBoard, + DeckStack, + DeckCard, + DeckLabel, + DeckACL, + DeckAttachment, + DeckComment, + DeckSession, + DeckConfig, +) + + +class DeckClient(BaseNextcloudClient): + """Client for Nextcloud Deck app operations.""" + + def _get_deck_headers( + self, additional_headers: Optional[Dict[str, str]] = None + ) -> Dict[str, str]: + """Get standard headers required for Deck API calls.""" + headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"} + if additional_headers: + headers.update(additional_headers) + return headers + + # Boards + async def get_boards( + self, details: bool = False, if_modified_since: Optional[str] = None + ) -> List[DeckBoard]: + additional_headers = {} + if if_modified_since: + additional_headers["If-Modified-Since"] = if_modified_since + headers = self._get_deck_headers(additional_headers) + params = {"details": "true"} if details else {} + response = await self._make_request( + "GET", "/apps/deck/api/v1.0/boards", headers=headers, params=params + ) + return [DeckBoard(**board) for board in response.json()] + + async def create_board(self, title: str, color: str) -> DeckBoard: + json_data = {"title": title, "color": color} + headers = self._get_deck_headers() + response = await self._make_request( + "POST", "/apps/deck/api/v1.0/boards", json=json_data, headers=headers + ) + return DeckBoard(**response.json()) + + async def get_board(self, board_id: int) -> DeckBoard: + headers = self._get_deck_headers() + response = await self._make_request( + "GET", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers + ) + return DeckBoard(**response.json()) + + async def update_board( + self, + board_id: int, + title: Optional[str] = None, + color: Optional[str] = None, + archived: Optional[bool] = None, + ) -> None: + json_data = {} + if title is not None: + json_data["title"] = title + if color is not None: + json_data["color"] = color + if archived is not None: + json_data["archived"] = archived + headers = self._get_deck_headers() + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}", + json=json_data, + headers=headers, + ) + + async def delete_board(self, board_id: int) -> None: + headers = self._get_deck_headers() + await self._make_request( + "DELETE", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers + ) + + async def undo_delete_board(self, board_id: int) -> None: + headers = self._get_deck_headers() + await self._make_request( + "POST", + f"/apps/deck/api/v1.0/boards/{board_id}/undo_delete", + headers=headers, + ) + + async def add_acl_rule( + self, + board_id: int, + type: int, + participant: str, + permission_edit: bool, + permission_share: bool, + permission_manage: bool, + ) -> List[DeckACL]: + json_data = { + "type": type, + "participant": participant, + "permissionEdit": permission_edit, + "permissionShare": permission_share, + "permissionManage": permission_manage, + } + response = await self._make_request( + "POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data + ) + return [DeckACL(**acl) for acl in response.json()] + + async def update_acl_rule( + self, + board_id: int, + acl_id: int, + permission_edit: Optional[bool] = None, + permission_share: Optional[bool] = None, + permission_manage: Optional[bool] = None, + ) -> None: + json_data = {} + if permission_edit is not None: + json_data["permissionEdit"] = permission_edit + if permission_share is not None: + json_data["permissionShare"] = permission_share + if permission_manage is not None: + json_data["permissionManage"] = permission_manage + await self._make_request( + "PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data + ) + + async def delete_acl_rule(self, board_id: int, acl_id: int) -> None: + await self._make_request( + "DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}" + ) + + async def clone_board( + self, + board_id: int, + with_cards: bool = False, + with_assignments: bool = False, + with_labels: bool = False, + with_due_date: bool = False, + move_cards_to_left_stack: bool = False, + restore_archived_cards: bool = False, + ) -> DeckBoard: + json_data = { + "withCards": with_cards, + "withAssignments": with_assignments, + "withLabels": with_labels, + "withDueDate": with_due_date, + "moveCardsToLeftStack": move_cards_to_left_stack, + "restoreArchivedCards": restore_archived_cards, + } + response = await self._make_request( + "POST", f"/apps/deck/api/v1.0/boards/{board_id}/clone", json=json_data + ) + return DeckBoard(**response.json()) + + # Stacks + async def get_stacks( + self, board_id: int, if_modified_since: Optional[str] = None + ) -> List[DeckStack]: + additional_headers = {} + if if_modified_since: + additional_headers["If-Modified-Since"] = if_modified_since + headers = self._get_deck_headers(additional_headers) + response = await self._make_request( + "GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks", headers=headers + ) + return [DeckStack(**stack) for stack in response.json()] + + async def get_archived_stacks(self, board_id: int) -> List[DeckStack]: + response = await self._make_request( + "GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/archived" + ) + return [DeckStack(**stack) for stack in response.json()] + + async def get_stack(self, board_id: int, stack_id: int) -> DeckStack: + response = await self._make_request( + "GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}" + ) + return DeckStack(**response.json()) + + async def create_stack(self, board_id: int, title: str, order: int) -> DeckStack: + json_data = {"title": title, "order": order} + headers = self._get_deck_headers() + response = await self._make_request( + "POST", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks", + json=json_data, + headers=headers, + ) + return DeckStack(**response.json()) + + async def update_stack( + self, + board_id: int, + stack_id: int, + title: Optional[str] = None, + order: Optional[int] = None, + ) -> None: + json_data = {} + if title is not None: + json_data["title"] = title + if order is not None: + json_data["order"] = order + headers = self._get_deck_headers() + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}", + json=json_data, + headers=headers, + ) + + async def delete_stack(self, board_id: int, stack_id: int) -> None: + await self._make_request( + "DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}" + ) + + # Cards + async def get_card(self, board_id: int, stack_id: int, card_id: int) -> DeckCard: + headers = self._get_deck_headers() + response = await self._make_request( + "GET", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}", + headers=headers, + ) + return DeckCard(**response.json()) + + async def create_card( + self, + board_id: int, + stack_id: int, + title: str, + type: str = "plain", + order: int = 999, + description: Optional[str] = None, + duedate: Optional[str] = None, + ) -> DeckCard: + json_data = { + "title": title, + "type": type, + "order": order, + } + if description is not None: + json_data["description"] = description + if duedate is not None: + json_data["duedate"] = duedate + headers = self._get_deck_headers() + response = await self._make_request( + "POST", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards", + json=json_data, + headers=headers, + ) + return DeckCard(**response.json()) + + async def update_card( + self, + board_id: int, + stack_id: int, + card_id: int, + title: Optional[str] = None, + description: Optional[str] = None, + type: Optional[str] = None, + owner: Optional[str] = None, + order: Optional[int] = None, + duedate: Optional[str] = None, + archived: Optional[bool] = None, + done: Optional[str] = None, + ) -> None: + # First, get the current card to use existing values for required fields + current_card = await self.get_card(board_id, stack_id, card_id) + + json_data = {} + if title is not None: + json_data["title"] = title + if description is not None: + json_data["description"] = description + # Type is required by the API, use provided or keep current + json_data["type"] = type if type is not None else current_card.type + # Owner is required by the API, use provided or keep current + json_data["owner"] = ( + owner + if owner is not None + else ( + current_card.owner + if isinstance(current_card.owner, str) + else current_card.owner.uid + if hasattr(current_card.owner, "uid") + else current_card.owner.primaryKey + ) + ) + if order is not None: + json_data["order"] = order + if duedate is not None: + json_data["duedate"] = duedate + if archived is not None: + json_data["archived"] = archived + if done is not None: + json_data["done"] = done + headers = self._get_deck_headers() + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}", + json=json_data, + headers=headers, + ) + + async def delete_card(self, board_id: int, stack_id: int, card_id: int) -> None: + headers = self._get_deck_headers() + await self._make_request( + "DELETE", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}", + headers=headers, + ) + + async def archive_card(self, board_id: int, stack_id: int, card_id: int) -> None: + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/archive", + ) + + async def unarchive_card(self, board_id: int, stack_id: int, card_id: int) -> None: + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unarchive", + ) + + async def assign_label_to_card( + self, board_id: int, stack_id: int, card_id: int, label_id: int + ) -> None: + json_data = {"labelId": label_id} + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel", + json=json_data, + ) + + async def remove_label_from_card( + self, board_id: int, stack_id: int, card_id: int, label_id: int + ) -> None: + json_data = {"labelId": label_id} + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/removeLabel", + json=json_data, + ) + + async def assign_user_to_card( + self, board_id: int, stack_id: int, card_id: int, user_id: str + ) -> None: + json_data = {"userId": user_id} + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignUser", + json=json_data, + ) + + async def unassign_user_from_card( + self, board_id: int, stack_id: int, card_id: int, user_id: str + ) -> None: + json_data = {"userId": user_id} + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unassignUser", + json=json_data, + ) + + async def reorder_card( + self, + board_id: int, + stack_id: int, + card_id: int, + order: int, + target_stack_id: int, + ) -> None: + json_data = {"order": order, "stackId": target_stack_id} + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder", + json=json_data, + ) + + # Labels + async def get_label(self, board_id: int, label_id: int) -> DeckLabel: + headers = self._get_deck_headers() + response = await self._make_request( + "GET", + f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}", + headers=headers, + ) + return DeckLabel(**response.json()) + + async def create_label(self, board_id: int, title: str, color: str) -> DeckLabel: + json_data = {"title": title, "color": color} + headers = self._get_deck_headers() + response = await self._make_request( + "POST", + f"/apps/deck/api/v1.0/boards/{board_id}/labels", + json=json_data, + headers=headers, + ) + return DeckLabel(**response.json()) + + async def update_label( + self, + board_id: int, + label_id: int, + title: Optional[str] = None, + color: Optional[str] = None, + ) -> None: + json_data = {} + if title is not None: + json_data["title"] = title + if color is not None: + json_data["color"] = color + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}", + json=json_data, + ) + + async def delete_label(self, board_id: int, label_id: int) -> None: + await self._make_request( + "DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}" + ) + + # Attachments + async def get_attachments( + self, board_id: int, stack_id: int, card_id: int + ) -> List[DeckAttachment]: + response = await self._make_request( + "GET", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments", + ) + return [DeckAttachment(**attachment) for attachment in response.json()] + + async def get_attachment_file( + self, board_id: int, stack_id: int, card_id: int, attachment_id: int + ) -> Any: + # This endpoint returns the raw file, so we return the raw response content + response = await self._make_request( + "GET", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}", + ) + return response.content + + async def upload_attachment( + self, + board_id: int, + stack_id: int, + card_id: int, + file_data: bytes, + file_type: str = "file", + ) -> DeckAttachment: + # The API expects binary data directly, not JSON + headers = {"Content-Type": "application/octet-stream"} + params = {"type": file_type} + response = await self._make_request( + "POST", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments", + headers=headers, + params=params, + data=file_data, + ) + return DeckAttachment(**response.json()) + + async def update_attachment( + self, + board_id: int, + stack_id: int, + card_id: int, + attachment_id: int, + file_data: bytes, + file_type: str = "deck_file", + ) -> DeckAttachment: + headers = {"Content-Type": "application/octet-stream"} + params = {"type": file_type} + response = await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}", + headers=headers, + params=params, + data=file_data, + ) + return DeckAttachment(**response.json()) + + async def delete_attachment( + self, board_id: int, stack_id: int, card_id: int, attachment_id: int + ) -> None: + await self._make_request( + "DELETE", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}", + ) + + async def restore_attachment( + self, board_id: int, stack_id: int, card_id: int, attachment_id: int + ) -> None: + await self._make_request( + "PUT", + f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}/restore", + ) + + # OCS API Endpoints (Config, Comments, Sessions) + async def get_config(self) -> DeckConfig: + headers = {"OCS-APIRequest": "true", "Accept": "application/json"} + response = await self._make_request( + "GET", "/ocs/v2.php/apps/deck/api/v1.0/config", headers=headers + ) + return DeckConfig(**response.json()["ocs"]["data"]) + + async def set_config_value( + self, key: str, value: Any, board_id: Optional[int] = None + ) -> Any: + path = f"/ocs/v2.php/apps/deck/api/v1.0/config/{key}" + if board_id: + path = f"/ocs/v2.php/apps/deck/api/v1.0/config/board:{board_id}:{key}" + json_data = {"value": value} + response = await self._make_request( + "POST", + path, + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + return response.json()["ocs"]["data"] + + async def get_comments( + self, card_id: int, limit: int = 20, offset: int = 0 + ) -> List[DeckComment]: + params = {"limit": limit, "offset": offset} + response = await self._make_request( + "GET", + f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments", + params=params, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + return [DeckComment(**comment) for comment in response.json()["ocs"]["data"]] + + async def create_comment( + self, card_id: int, message: str, parent_id: Optional[int] = None + ) -> DeckComment: + json_data = {"message": message} + if parent_id is not None: + json_data["parentId"] = parent_id + response = await self._make_request( + "POST", + f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments", + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + return DeckComment(**response.json()["ocs"]["data"]) + + async def update_comment( + self, card_id: int, comment_id: int, message: str + ) -> DeckComment: + json_data = {"message": message} + response = await self._make_request( + "PUT", + f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}", + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + return DeckComment(**response.json()["ocs"]["data"]) + + async def delete_comment(self, card_id: int, comment_id: int) -> None: + await self._make_request( + "DELETE", + f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}", + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + + async def create_session(self, board_id: int) -> DeckSession: + json_data = {"boardId": board_id} + response = await self._make_request( + "PUT", + "/ocs/v2.php/apps/deck/api/v1.0/session/create", + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + return DeckSession(**response.json()["ocs"]["data"]) + + async def sync_session(self, board_id: int, token: str) -> None: + json_data = {"boardId": board_id, "token": token} + await self._make_request( + "POST", + "/ocs/v2.php/apps/deck/api/v1.0/session/sync", + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + + async def close_session(self, board_id: int, token: str) -> None: + json_data = {"boardId": board_id, "token": token} + await self._make_request( + "POST", + "/ocs/v2.php/apps/deck/api/v1.0/session/close", + json=json_data, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py new file mode 100644 index 0000000..c1259d0 --- /dev/null +++ b/nextcloud_mcp_server/models/deck.py @@ -0,0 +1,183 @@ +from datetime import datetime +from typing import List, Optional, Dict, Any, Union + +from pydantic import BaseModel, Field, field_validator + +from .base import BaseResponse, StatusResponse + + +class DeckUser(BaseModel): + primaryKey: str + uid: str + displayname: str + + +class DeckPermissions(BaseModel): + PERMISSION_READ: bool + PERMISSION_EDIT: bool + PERMISSION_MANAGE: bool + PERMISSION_SHARE: bool + + +class DeckLabel(BaseModel): + id: int + title: str + color: str + boardId: Optional[int] = None + cardId: Optional[int] = None + + +class DeckACL(BaseModel): + id: int + participant: DeckUser + type: int + boardId: int + permissionEdit: bool + permissionShare: bool + permissionManage: bool + owner: bool + + +class DeckBoardSettings(BaseModel): + calendar: bool + cardDetailsInModal: Optional[bool] = Field(default=None, alias="cardDetailsInModal") + cardIdBadge: Optional[bool] = Field(default=None, alias="cardIdBadge") + groupLimit: Optional[List[Dict[str, str]]] = Field(default=None, alias="groupLimit") + notify_due: Optional[str] = Field(default=None, alias="notify-due") + + +class DeckBoard(BaseModel): + id: int + title: str + owner: DeckUser + color: str + archived: bool + labels: List[DeckLabel] + acl: List[DeckACL] + permissions: DeckPermissions + users: List[DeckUser] + deletedAt: int + lastModified: Optional[int] = None + settings: Optional[DeckBoardSettings] = None + etag: Optional[str] = Field(default=None, alias="ETag") + + @field_validator("settings", mode="before") + @classmethod + def validate_settings(cls, v): + # Handle case where API returns empty array instead of dict/null + if isinstance(v, list) and len(v) == 0: + return None + return v + + +class DeckCard(BaseModel): + id: int + title: str + stackId: int + type: str + order: int + archived: bool + owner: Union[str, DeckUser] # Can be either string or user object + description: Optional[str] = None + duedate: Optional[datetime] = None + done: Optional[datetime] = None + lastModified: Optional[int] = None + createdAt: Optional[int] = None + labels: Optional[List[DeckLabel]] = None + assignedUsers: Optional[List[DeckUser]] = None + attachments: Optional[List[Any]] = None # Define a proper Attachment model later + attachmentCount: Optional[int] = None + deletedAt: Optional[int] = None + commentsUnread: Optional[int] = None + overdue: Optional[int] = None + etag: Optional[str] = Field(default=None, alias="ETag") + + @field_validator("owner", mode="before") + @classmethod + def validate_owner(cls, v): + # Handle case where API returns user object instead of string + if isinstance(v, dict): + return v.get("uid", v.get("primaryKey", str(v))) + return v + + +class DeckStack(BaseModel): + id: int + title: str + boardId: int + order: int + deletedAt: int + lastModified: Optional[int] = None + cards: Optional[List[DeckCard]] = None + etag: Optional[str] = Field(default=None, alias="ETag") + + +class DeckAttachmentExtendedData(BaseModel): + filesize: int + mimetype: str + info: Dict[str, str] + + +class DeckAttachment(BaseModel): + id: int + cardId: int + type: str + data: str + lastModified: int + createdAt: int + createdBy: str + deletedAt: int + extendedData: DeckAttachmentExtendedData + + +class DeckComment(BaseModel): + id: int + objectId: int + message: str + actorId: str + actorType: str + actorDisplayName: str + creationDateTime: datetime + mentions: List[Dict[str, str]] + replyTo: Optional[Any] = None # Self-referencing, handle later if needed + + +class DeckSession(BaseModel): + token: str + + +class DeckConfig(BaseModel): + calendar: bool + cardDetailsInModal: bool + cardIdBadge: bool + groupLimit: Optional[List[Dict[str, str]]] = None + + +# Response Models for MCP Tools + + +class ListBoardsResponse(BaseResponse): + """Response model for listing deck boards.""" + + boards: List[DeckBoard] = Field(description="List of deck boards") + total: int = Field(description="Total number of boards") + + +class CreateBoardResponse(BaseResponse): + """Response model for board creation.""" + + id: int = Field(description="The created board ID") + title: str = Field(description="The created board title") + color: str = Field(description="The created board color") + + +class GetBoardResponse(BaseResponse): + """Response model for getting board details.""" + + board: DeckBoard = Field(description="Board details") + + +class BoardOperationResponse(StatusResponse): + """Response model for board operations like update/delete.""" + + board_id: int = Field(description="ID of the affected board") diff --git a/nextcloud_mcp_server/server/__init__.py b/nextcloud_mcp_server/server/__init__.py index f3b510b..9f806bb 100644 --- a/nextcloud_mcp_server/server/__init__.py +++ b/nextcloud_mcp_server/server/__init__.py @@ -3,11 +3,13 @@ from .notes import configure_notes_tools from .tables import configure_tables_tools from .webdav import configure_webdav_tools from .contacts import configure_contacts_tools +from .deck import configure_deck_tools __all__ = [ "configure_calendar_tools", + "configure_contacts_tools", + "configure_deck_tools", "configure_notes_tools", "configure_tables_tools", "configure_webdav_tools", - "configure_contacts_tools", ] diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py new file mode 100644 index 0000000..14ba756 --- /dev/null +++ b/nextcloud_mcp_server/server/deck.py @@ -0,0 +1,76 @@ +import logging +from typing import Optional + +from mcp.server.fastmcp import Context, FastMCP + +from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.models.deck import ( + ListBoardsResponse, + CreateBoardResponse, + GetBoardResponse, +) + +logger = logging.getLogger(__name__) + + +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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return board.model_dump() + + # Tools + @mcp.tool() + async def deck_list_boards( + ctx: Context, details: bool = False, if_modified_since: Optional[str] = None + ) -> ListBoardsResponse: + """List all Nextcloud Deck boards + + Args: + details: Enhance boards with details about labels, stacks and users + if_modified_since: Limit results to entities changed after this time (IMF-fixdate format) + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + boards = await client.deck.get_boards( + details=details, if_modified_since=if_modified_since + ) + return ListBoardsResponse(boards=boards, total=len(boards)) + + @mcp.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: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.create_board(title, color) + return CreateBoardResponse(id=board.id, title=board.title, color=board.color) + + @mcp.tool() + async def deck_get_board(ctx: Context, board_id: int) -> GetBoardResponse: + """Get details of a specific Nextcloud Deck board + + Args: + board_id: The ID of the board + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return GetBoardResponse(board=board) diff --git a/tests/conftest.py b/tests/conftest.py index a514634..155099f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -250,3 +250,149 @@ async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: s logger.error( f"Unexpected error deleting temporary contact {contact_uid}: {e}" ) + + +@pytest.fixture +async def temporary_board(nc_client: NextcloudClient): + """ + Fixture to create a temporary deck board for tests and ensure its deletion afterward. + Yields the created board data dict. + """ + board_id = None + unique_suffix = uuid.uuid4().hex[:8] + board_title = f"Temporary Test Board {unique_suffix}" + board_color = "FF0000" # Red color + created_board_data = None + + logger.info(f"Creating temporary deck board: {board_title}") + try: + created_board = await nc_client.deck.create_board(board_title, board_color) + board_id = created_board.id + created_board_data = { + "id": board_id, + "title": created_board.title, + "color": created_board.color, + "archived": getattr(created_board, "archived", False), + } + + logger.info(f"Temporary board created with ID: {board_id}") + yield created_board_data + + finally: + if board_id: + logger.info(f"Cleaning up temporary board ID: {board_id}") + try: + await nc_client.deck.delete_board(board_id) + logger.info(f"Successfully deleted temporary board ID: {board_id}") + except HTTPStatusError as e: + # Ignore 404 if board was already deleted by the test itself + if e.response.status_code not in [404, 403]: + logger.error(f"HTTP error deleting temporary board {board_id}: {e}") + else: + logger.warning( + f"Temporary board {board_id} already deleted or access denied ({e.response.status_code})." + ) + except Exception as e: + logger.error( + f"Unexpected error deleting temporary board {board_id}: {e}" + ) + + +@pytest.fixture +async def temporary_board_with_stack(nc_client: NextcloudClient, temporary_board: dict): + """ + Fixture to create a temporary stack in a temporary board. + Yields a tuple: (board_data, stack_data). + Depends on the temporary_board fixture. + """ + board_data = temporary_board + board_id = board_data["id"] + unique_suffix = uuid.uuid4().hex[:8] + stack_title = f"Test Stack {unique_suffix}" + stack_order = 1 + stack = None + + logger.info(f"Creating temporary stack in board ID: {board_id}") + try: + stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order) + stack_data = { + "id": stack.id, + "title": stack.title, + "order": stack.order, + "boardId": board_id, + } + + logger.info(f"Temporary stack created with ID: {stack.id}") + yield (board_data, stack_data) + + finally: + # Clean up - delete stack + if stack and hasattr(stack, "id"): + logger.info(f"Cleaning up temporary stack ID: {stack.id}") + try: + await nc_client.deck.delete_stack(board_id, stack.id) + logger.info(f"Successfully deleted temporary stack ID: {stack.id}") + except HTTPStatusError as e: + if e.response.status_code not in [404, 403]: + logger.error(f"HTTP error deleting temporary stack {stack.id}: {e}") + else: + logger.warning( + f"Temporary stack {stack.id} already deleted or access denied ({e.response.status_code})." + ) + except Exception as e: + logger.error( + f"Unexpected error deleting temporary stack {stack.id}: {e}" + ) + + +@pytest.fixture +async def temporary_board_with_card( + nc_client: NextcloudClient, temporary_board_with_stack: tuple +): + """ + Fixture to create a temporary card in a temporary stack within a temporary board. + Yields a tuple: (board_data, stack_data, card_data). + Depends on the temporary_board_with_stack fixture. + """ + board_data, stack_data = temporary_board_with_stack + board_id = board_data["id"] + stack_id = stack_data["id"] + unique_suffix = uuid.uuid4().hex[:8] + card_title = f"Test Card {unique_suffix}" + card_description = f"Test description for card {unique_suffix}" + card = None + + logger.info( + f"Creating temporary card in stack ID: {stack_id}, board ID: {board_id}" + ) + try: + card = await nc_client.deck.create_card( + board_id, stack_id, card_title, description=card_description + ) + card_data = { + "id": card.id, + "title": card.title, + "description": card.description, + "stackId": stack_id, + "boardId": board_id, + } + + logger.info(f"Temporary card created with ID: {card.id}") + yield (board_data, stack_data, card_data) + + finally: + # Clean up - delete card + if card and hasattr(card, "id"): + logger.info(f"Cleaning up temporary card ID: {card.id}") + try: + await nc_client.deck.delete_card(board_id, stack_id, card.id) + logger.info(f"Successfully deleted temporary card ID: {card.id}") + except HTTPStatusError as e: + if e.response.status_code not in [404, 403]: + logger.error(f"HTTP error deleting temporary card {card.id}: {e}") + else: + logger.warning( + f"Temporary card {card.id} already deleted or access denied ({e.response.status_code})." + ) + except Exception as e: + logger.error(f"Unexpected error deleting temporary card {card.id}: {e}") diff --git a/tests/integration/test_deck_api.py b/tests/integration/test_deck_api.py new file mode 100644 index 0000000..c9b2f86 --- /dev/null +++ b/tests/integration/test_deck_api.py @@ -0,0 +1,327 @@ +import logging +import uuid + +import pytest +from httpx import HTTPStatusError + +from nextcloud_mcp_server.client import NextcloudClient +from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +# Board CRUD Tests + + +async def test_deck_board_crud_workflow( + nc_client: NextcloudClient, temporary_board: dict +): + """ + Test complete board CRUD workflow using the temporary_board fixture. + """ + board_data = temporary_board + board_id = board_data["id"] + original_title = board_data["title"] + original_color = board_data["color"] + + logger.info(f"Testing CRUD operations on board ID: {board_id}") + + # Read the board + read_board = await nc_client.deck.get_board(board_id) + assert read_board.id == board_id + assert read_board.title == original_title + assert read_board.color == original_color + logger.info(f"Successfully read board ID: {board_id}") + + # Update the board + updated_title = f"Updated {original_title}" + updated_color = "00FF00" # Green color + await nc_client.deck.update_board( + board_id, title=updated_title, color=updated_color + ) + + # Verify the update + updated_board = await nc_client.deck.get_board(board_id) + assert updated_board.title == updated_title + assert updated_board.color == updated_color + logger.info(f"Successfully updated board ID: {board_id}") + + +async def test_deck_list_boards(nc_client: NextcloudClient): + """ + Test listing all boards with different options. + """ + # Test basic listing + boards = await nc_client.deck.get_boards() + assert isinstance(boards, list) + logger.info(f"Found {len(boards)} boards") + + # Test with details + detailed_boards = await nc_client.deck.get_boards(details=True) + assert isinstance(detailed_boards, list) + logger.info(f"Found {len(detailed_boards)} boards with details") + + +async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient): + """ + Test operations on non-existent board return appropriate errors. + """ + non_existent_id = 999999999 + + # Test get non-existent board + with pytest.raises(HTTPStatusError) as excinfo: + await nc_client.deck.get_board(non_existent_id) + assert excinfo.value.response.status_code in [ + 404, + 403, + ] # 403 might be returned for access denied + logger.info( + f"Get non-existent board correctly failed with {excinfo.value.response.status_code}" + ) + + # Test update non-existent board + with pytest.raises(HTTPStatusError) as excinfo: + await nc_client.deck.update_board(non_existent_id, title="Should Fail") + assert excinfo.value.response.status_code in [ + 404, + 403, + 400, + ] # 400 for bad request on invalid board ID + logger.info( + f"Update non-existent board correctly failed with {excinfo.value.response.status_code}" + ) + + +# Stack CRUD Tests + + +async def test_deck_stack_crud_workflow( + nc_client: NextcloudClient, temporary_board: dict +): + """ + Test complete stack CRUD workflow. + """ + board_id = temporary_board["id"] + stack_title = f"Test Stack {uuid.uuid4().hex[:8]}" + stack_order = 1 + stack = None + + try: + # Create stack + stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order) + assert isinstance(stack, DeckStack) + assert stack.title == stack_title + assert stack.order == stack_order + stack_id = stack.id + logger.info(f"Created stack ID: {stack_id}") + + # Read stack + read_stack = await nc_client.deck.get_stack(board_id, stack_id) + assert read_stack.id == stack_id + assert read_stack.title == stack_title + logger.info(f"Successfully read stack ID: {stack_id}") + + # Update stack + updated_title = f"Updated {stack_title}" + updated_order = 2 + await nc_client.deck.update_stack( + board_id, stack_id, title=updated_title, order=updated_order + ) + + # Verify update + updated_stack = await nc_client.deck.get_stack(board_id, stack_id) + assert updated_stack.title == updated_title + assert updated_stack.order == updated_order + logger.info(f"Successfully updated stack ID: {stack_id}") + + # List stacks + stacks = await nc_client.deck.get_stacks(board_id) + assert isinstance(stacks, list) + assert any(s.id == stack_id for s in stacks) + logger.info(f"Found stack ID: {stack_id} in board stacks list") + + finally: + # Clean up - delete stack + if stack and hasattr(stack, "id"): + try: + await nc_client.deck.delete_stack(board_id, stack.id) + logger.info(f"Cleaned up stack ID: {stack.id}") + except Exception as e: + logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}") + + +# Card CRUD Tests + + +async def test_deck_card_crud_workflow( + nc_client: NextcloudClient, temporary_board_with_stack: tuple +): + """ + Test complete card CRUD workflow. + """ + board_data, stack_data = temporary_board_with_stack + board_id = board_data["id"] + stack_id = stack_data["id"] + + card_title = f"Test Card {uuid.uuid4().hex[:8]}" + card_description = f"Test description for card {uuid.uuid4().hex[:8]}" + card = None + + try: + # Create card + card = await nc_client.deck.create_card( + board_id, stack_id, card_title, description=card_description + ) + assert isinstance(card, DeckCard) + assert card.title == card_title + assert card.description == card_description + card_id = card.id + logger.info(f"Created card ID: {card_id}") + + # Read card + read_card = await nc_client.deck.get_card(board_id, stack_id, card_id) + assert read_card.id == card_id + assert read_card.title == card_title + logger.info(f"Successfully read card ID: {card_id}") + + # Update card + updated_title = f"Updated {card_title}" + updated_description = f"Updated description for {card_title}" + await nc_client.deck.update_card( + board_id, + stack_id, + card_id, + title=updated_title, + description=updated_description, + ) + + # Verify update + updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id) + assert updated_card.title == updated_title + assert updated_card.description == updated_description + logger.info(f"Successfully updated card ID: {card_id}") + + # Archive and unarchive card + await nc_client.deck.archive_card(board_id, stack_id, card_id) + logger.info(f"Archived card ID: {card_id}") + + await nc_client.deck.unarchive_card(board_id, stack_id, card_id) + logger.info(f"Unarchived card ID: {card_id}") + + finally: + # Clean up - delete card + if card and hasattr(card, "id"): + try: + await nc_client.deck.delete_card(board_id, stack_id, card.id) + logger.info(f"Cleaned up card ID: {card.id}") + except Exception as e: + logger.warning(f"Failed to clean up card ID: {card.id}: {e}") + + +# Label CRUD Tests + + +async def test_deck_label_crud_workflow( + nc_client: NextcloudClient, temporary_board: dict +): + """ + Test complete label CRUD workflow. + """ + board_id = temporary_board["id"] + label_title = f"Test Label {uuid.uuid4().hex[:8]}" + label_color = "FF0000" # Red + label = None + + try: + # Create label + label = await nc_client.deck.create_label(board_id, label_title, label_color) + assert isinstance(label, DeckLabel) + assert label.title == label_title + assert label.color == label_color + label_id = label.id + logger.info(f"Created label ID: {label_id}") + + # Read label + read_label = await nc_client.deck.get_label(board_id, label_id) + assert read_label.id == label_id + assert read_label.title == label_title + logger.info(f"Successfully read label ID: {label_id}") + + # Update label + updated_title = f"Updated {label_title}" + updated_color = "00FF00" # Green + await nc_client.deck.update_label( + board_id, label_id, title=updated_title, color=updated_color + ) + + # Verify update + updated_label = await nc_client.deck.get_label(board_id, label_id) + assert updated_label.title == updated_title + assert updated_label.color == updated_color + logger.info(f"Successfully updated label ID: {label_id}") + + finally: + # Clean up - delete label + if label and hasattr(label, "id"): + try: + await nc_client.deck.delete_label(board_id, label.id) + logger.info(f"Cleaned up label ID: {label.id}") + except Exception as e: + logger.warning(f"Failed to clean up label ID: {label.id}: {e}") + + +# Configuration and Comments Tests + + +async def test_deck_config_operations(nc_client: NextcloudClient): + """ + Test deck configuration operations. + """ + # Get config + config = await nc_client.deck.get_config() + assert config is not None + logger.info(f"Retrieved deck config: {config}") + + +async def test_deck_comments_workflow( + nc_client: NextcloudClient, temporary_board_with_card: tuple +): + """ + Test comment operations on a card. + """ + board_data, stack_data, card_data = temporary_board_with_card + card_id = card_data["id"] + + comment_message = f"Test comment {uuid.uuid4().hex[:8]}" + comment = None + + try: + # Create comment + comment = await nc_client.deck.create_comment(card_id, comment_message) + assert comment.message == comment_message + comment_id = comment.id + logger.info(f"Created comment ID: {comment_id}") + + # List comments + comments = await nc_client.deck.get_comments(card_id) + assert isinstance(comments, list) + assert any(c.id == comment_id for c in comments) + logger.info(f"Found comment ID: {comment_id} in card comments") + + # Update comment + updated_message = f"Updated {comment_message}" + updated_comment = await nc_client.deck.update_comment( + card_id, comment_id, updated_message + ) + assert updated_comment.message == updated_message + logger.info(f"Successfully updated comment ID: {comment_id}") + + finally: + # Clean up - delete comment + if comment and hasattr(comment, "id"): + try: + await nc_client.deck.delete_comment(card_id, comment.id) + logger.info(f"Cleaned up comment ID: {comment.id}") + except Exception as e: + logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}") diff --git a/tests/integration/test_deck_mcp.py b/tests/integration/test_deck_mcp.py new file mode 100644 index 0000000..e86d4dd --- /dev/null +++ b/tests/integration/test_deck_mcp.py @@ -0,0 +1,268 @@ +import json +import logging +import uuid + +import pytest +from mcp import ClientSession + +from nextcloud_mcp_server.client import NextcloudClient + +logger = logging.getLogger(__name__) +pytestmark = pytest.mark.integration + + +async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): + """Test deck MCP tools are available and functional.""" + + # List available tools + tools = await nc_mcp_client.list_tools() + tool_names = [tool.name for tool in tools.tools] + + # Verify expected deck tools are present + expected_deck_tools = ["deck_list_boards", "deck_create_board", "deck_get_board"] + + for expected_tool in expected_deck_tools: + assert expected_tool in tool_names, ( + f"Expected deck tool '{expected_tool}' not found in available tools" + ) + logger.info(f"Found expected deck tool: {expected_tool}") + + # List available resource templates + templates = await nc_mcp_client.list_resource_templates() + template_uris = [template.uriTemplate for template in templates.resourceTemplates] + + # Verify expected deck resource templates + expected_deck_templates = [ + "nc://Deck/boards/{board_id}", + ] + + for expected_template in expected_deck_templates: + assert expected_template in template_uris, ( + f"Expected deck template '{expected_template}' not found" + ) + logger.info(f"Found expected deck resource template: {expected_template}") + + # List available resources + resources = await nc_mcp_client.list_resources() + resource_uris = [str(resource.uri) for resource in resources.resources] + + # Verify expected deck resources + expected_deck_resources = [ + "nc://Deck/boards", + ] + + for expected_resource in expected_deck_resources: + assert expected_resource in resource_uris, ( + f"Expected deck resource '{expected_resource}' not found" + ) + logger.info(f"Found expected deck resource: {expected_resource}") + + +async def test_deck_board_crud_workflow_mcp( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test complete Deck board CRUD workflow via MCP tools with verification via NextcloudClient.""" + + unique_suffix = uuid.uuid4().hex[:8] + board_title = f"MCP Test Board {unique_suffix}" + board_color = "0000FF" # Blue + + # 1. Create board via MCP + logger.info(f"Creating board via MCP: {board_title}") + create_result = await nc_mcp_client.call_tool( + "deck_create_board", + {"title": board_title, "color": board_color}, + ) + + assert create_result.isError is False, ( + f"MCP board creation failed: {create_result.content}" + ) + created_board_json = create_result.content[0].text + created_board_response = json.loads(created_board_json) + board_id = created_board_response["id"] + + logger.info(f"Board created via MCP with ID: {board_id}") + assert created_board_response["title"] == board_title + assert created_board_response["color"] == board_color + + # 2. Verify creation via direct NextcloudClient + direct_board = await nc_client.deck.get_board(board_id) + assert direct_board.title == board_title, ( + f"Title mismatch: {direct_board.title} != {board_title}" + ) + assert direct_board.color == board_color, "Color mismatch" + logger.info("Board creation verified via direct client") + + # 3. Read board via MCP resource + logger.info(f"Reading board via MCP resource: {board_id}") + read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}") + assert len(read_result.contents) == 1, "Expected exactly one content item" + read_board_data = json.loads(read_result.contents[0].text) + + assert read_board_data["title"] == board_title + assert read_board_data["color"] == board_color + logger.info("Board read via MCP resource successfully") + + # 4. Get board via MCP tool + logger.info(f"Getting board via MCP tool: {board_id}") + get_result = await nc_mcp_client.call_tool( + "deck_get_board", + {"board_id": board_id}, + ) + + assert get_result.isError is False, f"MCP board get failed: {get_result.content}" + get_board_response = json.loads(get_result.content[0].text) + get_board_data = get_board_response["board"] + assert get_board_data["title"] == board_title + assert get_board_data["color"] == board_color + logger.info("Board retrieved via MCP tool successfully") + + # 5. List boards via MCP tool + logger.info("Listing boards via MCP tool") + list_result = await nc_mcp_client.call_tool("deck_list_boards", {}) + assert list_result.isError is False, f"MCP board list failed: {list_result.content}" + boards_response = json.loads(list_result.content[0].text) + boards_data = boards_response["boards"] + assert isinstance(boards_data, list) + + # Verify our board is in the list + board_ids = [board["id"] for board in boards_data] + assert board_id in board_ids, "Created board not found in list" + logger.info(f"Board {board_id} found in boards list") + + # 6. List boards with details via MCP tool + logger.info("Listing boards with details via MCP tool") + list_details_result = await nc_mcp_client.call_tool( + "deck_list_boards", {"details": True} + ) + assert list_details_result.isError is False, ( + f"MCP board list with details failed: {list_details_result.content}" + ) + detailed_boards_response = json.loads(list_details_result.content[0].text) + detailed_boards_data = detailed_boards_response["boards"] + assert isinstance(detailed_boards_data, list) + logger.info("Boards listed with details successfully") + + # 7. Read boards list via MCP resource + logger.info("Reading boards list via MCP resource") + boards_resource_result = await nc_mcp_client.read_resource("nc://Deck/boards") + assert len(boards_resource_result.contents) == 1, ( + "Expected exactly one content item" + ) + boards_resource_data = json.loads(boards_resource_result.contents[0].text) + assert isinstance(boards_resource_data, list) # Resources return raw lists + + # Verify our board is in the resource list + resource_board_ids = [board["id"] for board in boards_resource_data] + assert board_id in resource_board_ids, "Created board not found in resource list" + logger.info("Board found in boards resource list") + + # Clean up - delete board + await nc_client.deck.delete_board(board_id) + logger.info(f"Cleaned up board ID: {board_id}") + + +async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSession): + """Test MCP deck tools handle errors appropriately.""" + + non_existent_id = 999999999 + + # Test get non-existent board via MCP tool + logger.info(f"Testing get non-existent board via MCP: {non_existent_id}") + get_result = await nc_mcp_client.call_tool( + "deck_get_board", + {"board_id": non_existent_id}, + ) + + assert get_result.isError is True, "Expected error for non-existent board" + logger.info("Get non-existent board correctly failed via MCP tool") + + # Test read non-existent board via MCP resource + logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}") + try: + read_result = await nc_mcp_client.read_resource( + f"nc://Deck/boards/{non_existent_id}" + ) + # If no error is thrown, check if the result indicates an error + assert len(read_result.contents) == 0, ( + "Expected empty content for non-existent board" + ) + except Exception as e: + logger.info(f"Read non-existent board correctly failed via MCP resource: {e}") + + +async def test_deck_board_creation_validation_mcp(nc_mcp_client: ClientSession): + """Test deck board creation validation via MCP tools.""" + + # Test creating board with empty title should fail + logger.info("Testing board creation with empty title via MCP") + create_result = await nc_mcp_client.call_tool( + "deck_create_board", + {"title": "", "color": "FF0000"}, + ) + + assert create_result.isError is True, "Expected error for empty board title" + logger.info("Empty title board creation correctly failed via MCP") + + +async def test_deck_board_creation_success_mcp( + nc_mcp_client: ClientSession, nc_client: NextcloudClient +): + """Test deck board creation with valid parameters via MCP tools.""" + + # Test creating board with valid parameters + logger.info("Testing board creation with valid parameters via MCP") + create_result = await nc_mcp_client.call_tool( + "deck_create_board", + {"title": f"Valid Board {uuid.uuid4().hex[:8]}", "color": "00FF00"}, + ) + + assert create_result.isError is False, "Valid board creation should succeed" + created_board = json.loads(create_result.content[0].text) + board_id = created_board["id"] + logger.info(f"Valid board created successfully with ID: {board_id}") + + # Clean up - delete board + await nc_client.deck.delete_board(board_id) + logger.info(f"Cleaned up board ID: {board_id}") + + +async def test_deck_workflow_integration_mcp( + nc_mcp_client: ClientSession, temporary_board_with_card: tuple +): + """Test a complete deck workflow using MCP tools with temporary resources.""" + + board_data, stack_data, card_data = temporary_board_with_card + board_id = board_data["id"] + board_title = board_data["title"] + + # 1. Read board via MCP to verify the structure + logger.info(f"Reading board via MCP resource: {board_id}") + read_result = await nc_mcp_client.read_resource(f"nc://Deck/boards/{board_id}") + board_mcp_data = json.loads(read_result.contents[0].text) + + assert board_mcp_data["title"] == board_title + logger.info("Board structure verified via MCP resource") + + # 2. List boards via MCP and verify our board is there + logger.info("Listing boards with details via MCP tool") + list_result = await nc_mcp_client.call_tool("deck_list_boards", {"details": True}) + boards_response = json.loads(list_result.content[0].text) + boards_data = boards_response["boards"] + + board_found = any(board["id"] == board_id for board in boards_data) + assert board_found, "Board not found in detailed list" + logger.info("Board found in detailed boards list") + + # 3. Get board via MCP tool and verify it matches our data + logger.info(f"Getting board via MCP tool: {board_id}") + get_result = await nc_mcp_client.call_tool( + "deck_get_board", + {"board_id": board_id}, + ) + + assert get_result.isError is False, "MCP board get failed" + get_board_response = json.loads(get_result.content[0].text) + get_board_data = get_board_response["board"] + assert get_board_data["title"] == board_title + logger.info("Board data verified via MCP tool") diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index d4b30cc..81f6158 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -51,6 +51,9 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): "nc_calendar_find_availability", "nc_calendar_bulk_operations", "nc_calendar_manage_calendar", + "deck_list_boards", + "deck_create_board", + "deck_get_board", ] for expected_tool in expected_tools: @@ -83,7 +86,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): resource_uris.append(str(resource.uri)) # Convert to string for comparison # Verify expected resources - expected_resources = ["nc://capabilities", "notes://settings"] + expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"] for expected_resource in expected_resources: assert expected_resource in resource_uris, ( From d2d413afcddd07805c4006dff51d8637e2ddd659 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 00:35:02 +0200 Subject: [PATCH 2/4] feat(deck): Add support for stack, cards, labels --- README.md | 128 +++++++- nextcloud_mcp_server/models/deck.py | 99 +++++- nextcloud_mcp_server/server/deck.py | 458 +++++++++++++++++++++++++++- tests/integration/test_deck_mcp.py | 48 +-- 4 files changed, 686 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 6f1fdd4..4306d47 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i | **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. | | **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. | | **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. | -| **Deck** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/75) | TBD | +| **Deck** | ✅ Full Support | Complete project management - boards, stacks, cards, labels, user assignments. Full CRUD operations and advanced features. | | **Tasks** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | TBD | Is there a Nextcloud app not present in this list that you'd like to be @@ -63,6 +63,30 @@ included? Feel free to open an issue, or contribute via a pull-request. | `nc_contacts_create_contact` | Create a new contact in an addressbook | | `nc_contacts_delete_contact` | Delete a contact from an addressbook | +### Deck Tools + +| Tool | Description | +|------|-------------| +| `deck_list_boards` | List all Nextcloud Deck boards with optional details and filtering | +| `deck_create_board` | Create a new Deck board with title and color | +| `deck_list_stacks` | List all stacks in a board | +| `deck_create_stack` | Create a new stack in a board | +| `deck_update_stack` | Update stack title and order | +| `deck_delete_stack` | Delete a stack and all its cards | +| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) | +| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) | +| `deck_delete_card` | Delete a card | +| `deck_archive_card` | Archive a card | +| `deck_unarchive_card` | Unarchive a card | +| `deck_reorder_card` | Move/reorder cards within or between stacks | +| `deck_create_label` | Create a new label in a board | +| `deck_update_label` | Update label title and color | +| `deck_delete_label` | Delete a label | +| `deck_assign_label_to_card` | Assign a label to a card | +| `deck_remove_label_from_card` | Remove a label from a card | +| `deck_assign_user_to_card` | Assign a user to a card | +| `deck_unassign_user_from_card` | Remove a user assignment from a card | + ### Tables Tools | Tool | Description | @@ -86,12 +110,41 @@ included? Feel free to open an issue, or contribute via a pull-request. ## Available Resources +Resources provide read-only access to data for browsing and discovery. Unlike tools, resources are automatically listed by MCP clients and enable LLMs to explore your Nextcloud data structure. + +### Core Resources | Resource | Description | |----------|-------------| | `nc://capabilities` | Access Nextcloud server capabilities | | `notes://settings` | Access Notes app settings | | `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes | +### Deck Resources +| Resource | Description | +|----------|-------------| +| `nc://Deck/boards` | List all deck boards | +| `nc://Deck/boards/{board_id}` | Get details of a specific board | +| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack | +| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card | +| `nc://Deck/boards/{board_id}/labels` | List all labels in a board | +| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label | + +### Tools vs Resources + +**Tools** are for actions and operations: +- Create, update, delete operations +- Structured responses with validation +- Error handling and business logic +- Examples: `deck_create_card`, `deck_update_stack` + +**Resources** are for data browsing and discovery: +- Read-only access to existing data +- Automatic listing by MCP clients +- Raw data format for exploration +- Examples: `nc://Deck/boards/{board_id}`, `nc://Deck/boards/{board_id}/stacks` + ### WebDAV File System Access The server provides complete file system access to your NextCloud instance, enabling you to: @@ -123,6 +176,77 @@ await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent he await nc_webdav_delete_resource("old_file.txt") ``` +### Deck Project Management + +The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows: + +- Create and manage boards, stacks, and cards +- Organize tasks with labels and user assignments +- Archive/unarchive cards and reorder within or between stacks +- Full CRUD operations on all Deck entities +- Browse project structure through hierarchical resources + +**Usage Examples:** + +```python +# Create a new project board +await deck_create_board(title="Website Redesign", color="1976D2") + +# Create workflow stacks +await deck_create_stack(board_id=1, title="To Do", order=1) +await deck_create_stack(board_id=1, title="In Progress", order=2) +await deck_create_stack(board_id=1, title="Done", order=3) + +# Create task cards with details +await deck_create_card( + board_id=1, + stack_id=1, + title="Design new homepage", + description="Create mockups for the new homepage layout", + type="plain", + order=1, + duedate="2025-08-15T17:00:00" +) + +# Create and assign labels for organization +await deck_create_label(board_id=1, title="High Priority", color="F44336") +await deck_create_label(board_id=1, title="UI/UX", color="9C27B0") + +# Assign labels and users to cards +await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1) +await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer") + +# Move cards through workflow +await deck_reorder_card( + board_id=1, + stack_id=1, # From "To Do" + card_id=1, + order=1, + target_stack_id=2 # To "In Progress" +) + +# Update task progress +await deck_update_card( + board_id=1, + stack_id=2, + card_id=1, + description="Homepage mockups completed, starting development", + order=1 +) + +# Complete tasks +await deck_reorder_card( + board_id=1, + stack_id=2, # From "In Progress" + card_id=1, + order=1, + target_stack_id=3 # To "Done" +) + +# Archive completed cards +await deck_archive_card(board_id=1, stack_id=3, card_id=1) +``` + ### Calendar Integration The server provides comprehensive calendar integration through CalDAV, enabling you to: @@ -308,7 +432,7 @@ See the full list of available `uvicorn` options and how to set them at [https:/ By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option: ```bash -# Available apps: notes, tables, webdav, calendar, contacts +# Available apps: notes, tables, webdav, calendar, contacts, deck # Enable all apps (default behavior) uv run python -m nextcloud_mcp_server.app diff --git a/nextcloud_mcp_server/models/deck.py b/nextcloud_mcp_server/models/deck.py index c1259d0..d46a3d2 100644 --- a/nextcloud_mcp_server/models/deck.py +++ b/nextcloud_mcp_server/models/deck.py @@ -70,6 +70,13 @@ class DeckBoard(BaseModel): return v +class DeckAssignedUser(BaseModel): + id: int + participant: DeckUser + cardId: int + type: int + + class DeckCard(BaseModel): id: int title: str @@ -84,7 +91,7 @@ class DeckCard(BaseModel): lastModified: Optional[int] = None createdAt: Optional[int] = None labels: Optional[List[DeckLabel]] = None - assignedUsers: Optional[List[DeckUser]] = None + assignedUsers: Optional[List[Union[DeckUser, DeckAssignedUser]]] = None attachments: Optional[List[Any]] = None # Define a proper Attachment model later attachmentCount: Optional[int] = None deletedAt: Optional[int] = None @@ -100,6 +107,27 @@ class DeckCard(BaseModel): return v.get("uid", v.get("primaryKey", str(v))) return v + @field_validator("assignedUsers", mode="before") + @classmethod + def validate_assigned_users(cls, v): + # Handle different formats of assigned users from the API + if not v: + return v + + validated_users = [] + for user in v: + if isinstance(user, dict): + # Check if it's an assignment object with participant + if "participant" in user: + validated_users.append(user) + # Check if it's a direct user object + elif "uid" in user or "primaryKey" in user: + validated_users.append(user) + else: + validated_users.append(user) + + return validated_users + class DeckStack(BaseModel): id: int @@ -171,13 +199,70 @@ class CreateBoardResponse(BaseResponse): color: str = Field(description="The created board color") -class GetBoardResponse(BaseResponse): - """Response model for getting board details.""" - - board: DeckBoard = Field(description="Board details") - - class BoardOperationResponse(StatusResponse): """Response model for board operations like update/delete.""" board_id: int = Field(description="ID of the affected board") + + +# Stack Response Models + + +class ListStacksResponse(BaseResponse): + """Response model for listing deck stacks.""" + + stacks: List[DeckStack] = Field(description="List of deck stacks") + total: int = Field(description="Total number of stacks") + + +class CreateStackResponse(BaseResponse): + """Response model for stack creation.""" + + id: int = Field(description="The created stack ID") + title: str = Field(description="The created stack title") + order: int = Field(description="The created stack order") + + +class StackOperationResponse(StatusResponse): + """Response model for stack operations like update/delete.""" + + stack_id: int = Field(description="ID of the affected stack") + board_id: int = Field(description="ID of the board containing the stack") + + +# Card Response Models + + +class CreateCardResponse(BaseResponse): + """Response model for card creation.""" + + id: int = Field(description="The created card ID") + title: str = Field(description="The created card title") + description: Optional[str] = Field(description="The created card description") + stackId: int = Field(description="The stack ID the card belongs to") + + +class CardOperationResponse(StatusResponse): + """Response model for card operations like update/delete.""" + + card_id: int = Field(description="ID of the affected card") + stack_id: int = Field(description="ID of the stack containing the card") + board_id: int = Field(description="ID of the board containing the card") + + +# Label Response Models + + +class CreateLabelResponse(BaseResponse): + """Response model for label creation.""" + + id: int = Field(description="The created label ID") + title: str = Field(description="The created label title") + color: str = Field(description="The created label color") + + +class LabelOperationResponse(StatusResponse): + """Response model for label operations like update/delete.""" + + label_id: int = Field(description="ID of the affected label") + board_id: int = Field(description="ID of the board containing the label") diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 14ba756..4809f64 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -7,7 +7,13 @@ from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.models.deck import ( ListBoardsResponse, CreateBoardResponse, - GetBoardResponse, + ListStacksResponse, + CreateStackResponse, + StackOperationResponse, + CreateCardResponse, + CardOperationResponse, + CreateLabelResponse, + LabelOperationResponse, ) logger = logging.getLogger(__name__) @@ -33,6 +39,56 @@ def configure_deck_tools(mcp: FastMCP): 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + board = await client.deck.get_board(board_id) + return [label.model_dump() for label in board.labels] + + @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() + client: NextcloudClient = ctx.request_context.lifespan_context.client + label = await client.deck.get_label(board_id, label_id) + return label.model_dump() + # Tools @mcp.tool() async def deck_list_boards( @@ -64,13 +120,405 @@ def configure_deck_tools(mcp: FastMCP): board = await client.deck.create_board(title, color) return CreateBoardResponse(id=board.id, title=board.title, color=board.color) + # Stack Tools @mcp.tool() - async def deck_get_board(ctx: Context, board_id: int) -> GetBoardResponse: - """Get details of a specific Nextcloud Deck board + async def deck_list_stacks( + ctx: Context, board_id: int, if_modified_since: Optional[str] = None + ) -> ListStacksResponse: + """List all stacks in a Nextcloud Deck board Args: board_id: The ID of the board + if_modified_since: Limit results to entities changed after this time (IMF-fixdate format) """ client: NextcloudClient = ctx.request_context.lifespan_context.client - board = await client.deck.get_board(board_id) - return GetBoardResponse(board=board) + stacks = await client.deck.get_stacks(board_id, if_modified_since) + return ListStacksResponse(stacks=stacks, total=len(stacks)) + + @mcp.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: NextcloudClient = ctx.request_context.lifespan_context.client + stack = await client.deck.create_stack(board_id, title, order) + return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) + + @mcp.tool() + async def deck_update_stack( + ctx: Context, + board_id: int, + stack_id: int, + title: Optional[str] = None, + order: Optional[int] = 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + async def deck_create_card( + ctx: Context, + board_id: int, + stack_id: int, + title: str, + type: str = "plain", + order: int = 999, + description: Optional[str] = None, + duedate: Optional[str] = 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + async def deck_update_card( + ctx: Context, + board_id: int, + stack_id: int, + card_id: int, + title: Optional[str] = None, + description: Optional[str] = None, + type: Optional[str] = None, + owner: Optional[str] = None, + order: Optional[int] = None, + duedate: Optional[str] = None, + archived: Optional[bool] = None, + done: Optional[str] = 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + async def deck_reorder_card( + ctx: Context, + board_id: int, + stack_id: int, + card_id: int, + order: int, + target_stack_id: int, + ) -> CardOperationResponse: + """Reorder/move a Nextcloud Deck card + + 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 + """ + client: NextcloudClient = ctx.request_context.lifespan_context.client + 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, + ) + + # Label Tools + @mcp.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: NextcloudClient = ctx.request_context.lifespan_context.client + label = await client.deck.create_label(board_id, title, color) + return CreateLabelResponse(id=label.id, title=label.title, color=label.color) + + @mcp.tool() + async def deck_update_label( + ctx: Context, + board_id: int, + label_id: int, + title: Optional[str] = None, + color: Optional[str] = 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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() + 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: NextcloudClient = ctx.request_context.lifespan_context.client + 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, + ) diff --git a/tests/integration/test_deck_mcp.py b/tests/integration/test_deck_mcp.py index e86d4dd..672054e 100644 --- a/tests/integration/test_deck_mcp.py +++ b/tests/integration/test_deck_mcp.py @@ -19,7 +19,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): tool_names = [tool.name for tool in tools.tools] # Verify expected deck tools are present - expected_deck_tools = ["deck_list_boards", "deck_create_board", "deck_get_board"] + expected_deck_tools = ["deck_list_boards", "deck_create_board"] for expected_tool in expected_deck_tools: assert expected_tool in tool_names, ( @@ -103,19 +103,10 @@ async def test_deck_board_crud_workflow_mcp( assert read_board_data["color"] == board_color logger.info("Board read via MCP resource successfully") - # 4. Get board via MCP tool - logger.info(f"Getting board via MCP tool: {board_id}") - get_result = await nc_mcp_client.call_tool( - "deck_get_board", - {"board_id": board_id}, - ) - - assert get_result.isError is False, f"MCP board get failed: {get_result.content}" - get_board_response = json.loads(get_result.content[0].text) - get_board_data = get_board_response["board"] - assert get_board_data["title"] == board_title - assert get_board_data["color"] == board_color - logger.info("Board retrieved via MCP tool successfully") + # 4. Verify board via direct read of resource + logger.info(f"Verifying board via resource read: {board_id}") + # This was already done in step 3, so we'll just log confirmation + logger.info("Board structure verified successfully") # 5. List boards via MCP tool logger.info("Listing boards via MCP tool") @@ -167,15 +158,15 @@ async def test_deck_board_operations_error_handling_mcp(nc_mcp_client: ClientSes non_existent_id = 999999999 - # Test get non-existent board via MCP tool - logger.info(f"Testing get non-existent board via MCP: {non_existent_id}") - get_result = await nc_mcp_client.call_tool( - "deck_get_board", - {"board_id": non_existent_id}, + # Test create board with invalid parameters via MCP tool + logger.info("Testing board creation with invalid parameters via MCP") + create_result = await nc_mcp_client.call_tool( + "deck_create_board", + {"title": "", "color": "FF0000"}, ) - assert get_result.isError is True, "Expected error for non-existent board" - logger.info("Get non-existent board correctly failed via MCP tool") + assert create_result.isError is True, "Expected error for invalid board creation" + logger.info("Invalid board creation correctly failed via MCP tool") # Test read non-existent board via MCP resource logger.info(f"Testing read non-existent board via MCP resource: {non_existent_id}") @@ -254,15 +245,6 @@ async def test_deck_workflow_integration_mcp( assert board_found, "Board not found in detailed list" logger.info("Board found in detailed boards list") - # 3. Get board via MCP tool and verify it matches our data - logger.info(f"Getting board via MCP tool: {board_id}") - get_result = await nc_mcp_client.call_tool( - "deck_get_board", - {"board_id": board_id}, - ) - - assert get_result.isError is False, "MCP board get failed" - get_board_response = json.loads(get_result.content[0].text) - get_board_data = get_board_response["board"] - assert get_board_data["title"] == board_title - logger.info("Board data verified via MCP tool") + # 3. Verify board data matches via resource (already done in step 1) + logger.info(f"Board data verification completed for board: {board_id}") + logger.info("Board structure and data verified successfully") From 652c58d1fbb004813f0022405767a814cf509793 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 00:40:16 +0200 Subject: [PATCH 3/4] chore: fix test --- tests/integration/test_mcp.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index 81f6158..aec5014 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -53,7 +53,6 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): "nc_calendar_manage_calendar", "deck_list_boards", "deck_create_board", - "deck_get_board", ] for expected_tool in expected_tools: From 7498b501eb26ab4bc5bbdb208364cee4297ce275 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Sep 2025 09:31:13 +0200 Subject: [PATCH 4/4] chore: Remove remaining tools --- nextcloud_mcp_server/server/deck.py | 30 -------------------- tests/integration/test_deck_mcp.py | 43 ++++++----------------------- tests/integration/test_mcp.py | 1 - 3 files changed, 8 insertions(+), 66 deletions(-) diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index 4809f64..034a430 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -5,9 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.models.deck import ( - ListBoardsResponse, CreateBoardResponse, - ListStacksResponse, CreateStackResponse, StackOperationResponse, CreateCardResponse, @@ -90,21 +88,6 @@ def configure_deck_tools(mcp: FastMCP): return label.model_dump() # Tools - @mcp.tool() - async def deck_list_boards( - ctx: Context, details: bool = False, if_modified_since: Optional[str] = None - ) -> ListBoardsResponse: - """List all Nextcloud Deck boards - - Args: - details: Enhance boards with details about labels, stacks and users - if_modified_since: Limit results to entities changed after this time (IMF-fixdate format) - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - boards = await client.deck.get_boards( - details=details, if_modified_since=if_modified_since - ) - return ListBoardsResponse(boards=boards, total=len(boards)) @mcp.tool() async def deck_create_board( @@ -121,19 +104,6 @@ def configure_deck_tools(mcp: FastMCP): return CreateBoardResponse(id=board.id, title=board.title, color=board.color) # Stack Tools - @mcp.tool() - async def deck_list_stacks( - ctx: Context, board_id: int, if_modified_since: Optional[str] = None - ) -> ListStacksResponse: - """List all stacks in a Nextcloud Deck board - - Args: - board_id: The ID of the board - if_modified_since: Limit results to entities changed after this time (IMF-fixdate format) - """ - client: NextcloudClient = ctx.request_context.lifespan_context.client - stacks = await client.deck.get_stacks(board_id, if_modified_since) - return ListStacksResponse(stacks=stacks, total=len(stacks)) @mcp.tool() async def deck_create_stack( diff --git a/tests/integration/test_deck_mcp.py b/tests/integration/test_deck_mcp.py index 672054e..ab3734d 100644 --- a/tests/integration/test_deck_mcp.py +++ b/tests/integration/test_deck_mcp.py @@ -19,7 +19,7 @@ async def test_deck_mcp_connectivity(nc_mcp_client: ClientSession): tool_names = [tool.name for tool in tools.tools] # Verify expected deck tools are present - expected_deck_tools = ["deck_list_boards", "deck_create_board"] + expected_deck_tools = ["deck_create_board"] for expected_tool in expected_deck_tools: assert expected_tool in tool_names, ( @@ -108,33 +108,7 @@ async def test_deck_board_crud_workflow_mcp( # This was already done in step 3, so we'll just log confirmation logger.info("Board structure verified successfully") - # 5. List boards via MCP tool - logger.info("Listing boards via MCP tool") - list_result = await nc_mcp_client.call_tool("deck_list_boards", {}) - assert list_result.isError is False, f"MCP board list failed: {list_result.content}" - boards_response = json.loads(list_result.content[0].text) - boards_data = boards_response["boards"] - assert isinstance(boards_data, list) - - # Verify our board is in the list - board_ids = [board["id"] for board in boards_data] - assert board_id in board_ids, "Created board not found in list" - logger.info(f"Board {board_id} found in boards list") - - # 6. List boards with details via MCP tool - logger.info("Listing boards with details via MCP tool") - list_details_result = await nc_mcp_client.call_tool( - "deck_list_boards", {"details": True} - ) - assert list_details_result.isError is False, ( - f"MCP board list with details failed: {list_details_result.content}" - ) - detailed_boards_response = json.loads(list_details_result.content[0].text) - detailed_boards_data = detailed_boards_response["boards"] - assert isinstance(detailed_boards_data, list) - logger.info("Boards listed with details successfully") - - # 7. Read boards list via MCP resource + # 5. Read boards list via MCP resource logger.info("Reading boards list via MCP resource") boards_resource_result = await nc_mcp_client.read_resource("nc://Deck/boards") assert len(boards_resource_result.contents) == 1, ( @@ -235,15 +209,14 @@ async def test_deck_workflow_integration_mcp( assert board_mcp_data["title"] == board_title logger.info("Board structure verified via MCP resource") - # 2. List boards via MCP and verify our board is there - logger.info("Listing boards with details via MCP tool") - list_result = await nc_mcp_client.call_tool("deck_list_boards", {"details": True}) - boards_response = json.loads(list_result.content[0].text) - boards_data = boards_response["boards"] + # 2. List boards via MCP resource and verify our board is there + logger.info("Listing boards via MCP resource") + list_result = await nc_mcp_client.read_resource("nc://Deck/boards") + boards_data = json.loads(list_result.contents[0].text) board_found = any(board["id"] == board_id for board in boards_data) - assert board_found, "Board not found in detailed list" - logger.info("Board found in detailed boards list") + assert board_found, "Board not found in boards list" + logger.info("Board found in boards list") # 3. Verify board data matches via resource (already done in step 1) logger.info(f"Board data verification completed for board: {board_id}") diff --git a/tests/integration/test_mcp.py b/tests/integration/test_mcp.py index aec5014..ea65c7d 100644 --- a/tests/integration/test_mcp.py +++ b/tests/integration/test_mcp.py @@ -51,7 +51,6 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession): "nc_calendar_find_availability", "nc_calendar_bulk_operations", "nc_calendar_manage_calendar", - "deck_list_boards", "deck_create_board", ]