feat(deck): Initialize Deck app client/server

This commit is contained in:
Chris Coutinho
2025-09-11 00:10:25 +02:00
parent b1eb4d2497
commit 167053578d
12 changed files with 1620 additions and 6 deletions
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
php /var/www/html/occ app:enable deck
+3 -3
View File
@@ -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:
+3 -1
View File
@@ -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(
+2
View File
@@ -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()
+602
View File
@@ -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"},
)
+183
View File
@@ -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")
+3 -1
View File
@@ -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",
]
+76
View File
@@ -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)
+146
View File
@@ -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}")
+327
View File
@@ -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}")
+268
View File
@@ -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")
+4 -1
View File
@@ -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, (