Compare commits
11 Commits
v0.9.0
...
feature/deck
| Author | SHA1 | Date | |
|---|---|---|---|
| 7498b501eb | |||
| 652c58d1fb | |||
| e7a5caa0d6 | |||
| d2d413afcd | |||
| 3c3df0d3a5 | |||
| c59bcca053 | |||
| 18973e061a | |||
| 167053578d | |||
| 2633b63a04 | |||
| 5d4902a73e | |||
| b55b9640c6 |
@@ -1,3 +1,10 @@
|
|||||||
|
## v0.10.0 (2025-09-10)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add WebDAV resource copy functionality
|
||||||
|
- Add WebDAV resource move/rename functionality
|
||||||
|
|
||||||
## v0.9.0 (2025-09-10)
|
## v0.9.0 (2025-09-10)
|
||||||
|
|
||||||
### BREAKING CHANGE
|
### BREAKING CHANGE
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:0.8.16-python3.11-alpine@sha256:6f2ebcb9ed454dbfd0f324dff39807d0edaac19560839667b0b52e37996212a1
|
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| **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. |
|
| **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. |
|
| **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 |
|
| **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
|
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_create_contact` | Create a new contact in an addressbook |
|
||||||
| `nc_contacts_delete_contact` | Delete a contact from 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
|
### Tables Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
@@ -83,15 +107,46 @@ included? Feel free to open an issue, or contribute via a pull-request.
|
|||||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||||
| `nc_webdav_create_directory` | Create new directories |
|
| `nc_webdav_create_directory` | Create new directories |
|
||||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||||
|
| `nc_webdav_move_resource` | Move or rename files and directories |
|
||||||
|
| `nc_webdav_copy_resource` | Copy files and directories |
|
||||||
|
|
||||||
## Available Resources
|
## 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 |
|
| Resource | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `nc://capabilities` | Access Nextcloud server capabilities |
|
| `nc://capabilities` | Access Nextcloud server capabilities |
|
||||||
| `notes://settings` | Access Notes app settings |
|
| `notes://settings` | Access Notes app settings |
|
||||||
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
|
| `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
|
### WebDAV File System Access
|
||||||
|
|
||||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||||
@@ -121,6 +176,95 @@ await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent he
|
|||||||
|
|
||||||
# Delete a file or directory
|
# Delete a file or directory
|
||||||
await nc_webdav_delete_resource("old_file.txt")
|
await nc_webdav_delete_resource("old_file.txt")
|
||||||
|
|
||||||
|
# Move or rename a file
|
||||||
|
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||||
|
|
||||||
|
# Move a file to another directory
|
||||||
|
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||||
|
|
||||||
|
# Move a directory
|
||||||
|
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||||
|
|
||||||
|
# Copy a file
|
||||||
|
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||||
|
|
||||||
|
# Copy a file to another directory
|
||||||
|
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||||
|
|
||||||
|
# Copy a directory
|
||||||
|
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
### Calendar Integration
|
||||||
@@ -308,7 +452,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:
|
By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Available apps: notes, tables, webdav, calendar, contacts
|
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
||||||
|
|
||||||
# Enable all apps (default behavior)
|
# Enable all apps (default behavior)
|
||||||
uv run python -m nextcloud_mcp_server.app
|
uv run python -m nextcloud_mcp_server.app
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable deck
|
||||||
+3
-3
@@ -46,15 +46,15 @@ services:
|
|||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
build: .
|
build: .
|
||||||
command: ["--host", "0.0.0.0", "--log-level", "debug"]
|
command: ["--host", "0.0.0.0"]
|
||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_USERNAME=admin
|
- NEXTCLOUD_USERNAME=admin
|
||||||
- NEXTCLOUD_PASSWORD=admin
|
- NEXTCLOUD_PASSWORD=admin
|
||||||
volumes:
|
#volumes:
|
||||||
- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_notes_tools,
|
configure_notes_tools,
|
||||||
configure_tables_tools,
|
configure_tables_tools,
|
||||||
configure_webdav_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,
|
"webdav": configure_webdav_tools,
|
||||||
"calendar": configure_calendar_tools,
|
"calendar": configure_calendar_tools,
|
||||||
"contacts": configure_contacts_tools,
|
"contacts": configure_contacts_tools,
|
||||||
|
"deck": configure_deck_tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If no specific apps are specified, enable all
|
# 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",
|
"--enable-app",
|
||||||
"-e",
|
"-e",
|
||||||
multiple=True,
|
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.",
|
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||||
)
|
)
|
||||||
def run(
|
def run(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from httpx import (
|
|||||||
from ..controllers.notes_search import NotesSearchController
|
from ..controllers.notes_search import NotesSearchController
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
from .contacts import ContactsClient
|
from .contacts import ContactsClient
|
||||||
|
from .deck import DeckClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
from .webdav import WebDAVClient
|
from .webdav import WebDAVClient
|
||||||
@@ -69,6 +70,7 @@ class NextcloudClient:
|
|||||||
self.tables = TablesClient(self._client, username)
|
self.tables = TablesClient(self._client, username)
|
||||||
self.calendar = CalendarClient(self._client, username)
|
self.calendar = CalendarClient(self._client, username)
|
||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
|
self.deck = DeckClient(self._client, username)
|
||||||
|
|
||||||
# Initialize controllers
|
# Initialize controllers
|
||||||
self._notes_search = NotesSearchController()
|
self._notes_search = NotesSearchController()
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -415,3 +415,158 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def move_resource(
|
||||||
|
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Move or rename a resource (file or directory) via WebDAV MOVE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: The path of the file or directory to move
|
||||||
|
destination_path: The new path for the file or directory
|
||||||
|
overwrite: Whether to overwrite the destination if it exists
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_code and optional message
|
||||||
|
"""
|
||||||
|
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||||
|
destination_webdav_path = (
|
||||||
|
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure paths have consistent trailing slashes for directories
|
||||||
|
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||||
|
destination_webdav_path += "/"
|
||||||
|
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||||
|
source_webdav_path += "/"
|
||||||
|
|
||||||
|
logger.debug(f"Moving resource from '{source_path}' to '{destination_path}'")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Destination": destination_webdav_path,
|
||||||
|
"Overwrite": "T" if overwrite else "F",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._make_request(
|
||||||
|
"MOVE", source_webdav_path, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Successfully moved resource from '{source_path}' to '{destination_path}'"
|
||||||
|
)
|
||||||
|
return {"status_code": response.status_code}
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
logger.debug(f"Source resource '{source_path}' not found")
|
||||||
|
return {"status_code": 404, "message": "Source resource not found"}
|
||||||
|
elif e.response.status_code == 412:
|
||||||
|
logger.debug(
|
||||||
|
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status_code": 412,
|
||||||
|
"message": "Destination already exists and overwrite is false",
|
||||||
|
}
|
||||||
|
elif e.response.status_code == 409:
|
||||||
|
logger.debug(
|
||||||
|
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status_code": 409,
|
||||||
|
"message": "Parent directory of destination doesn't exist",
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status_code": 409,
|
||||||
|
"message": "Parent directory of destination doesn't exist",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def copy_resource(
|
||||||
|
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Copy a resource (file or directory) via WebDAV COPY.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: The path of the file or directory to copy
|
||||||
|
destination_path: The destination path for the copy
|
||||||
|
overwrite: Whether to overwrite the destination if it exists
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_code and optional message
|
||||||
|
"""
|
||||||
|
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||||
|
destination_webdav_path = (
|
||||||
|
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure paths have consistent trailing slashes for directories
|
||||||
|
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||||
|
destination_webdav_path += "/"
|
||||||
|
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||||
|
source_webdav_path += "/"
|
||||||
|
|
||||||
|
logger.debug(f"Copying resource from '{source_path}' to '{destination_path}'")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Destination": destination_webdav_path,
|
||||||
|
"Overwrite": "T" if overwrite else "F",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._make_request(
|
||||||
|
"COPY", source_webdav_path, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Successfully copied resource from '{source_path}' to '{destination_path}'"
|
||||||
|
)
|
||||||
|
return {"status_code": response.status_code}
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
logger.debug(f"Source resource '{source_path}' not found")
|
||||||
|
return {"status_code": 404, "message": "Source resource not found"}
|
||||||
|
elif e.response.status_code == 412:
|
||||||
|
logger.debug(
|
||||||
|
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status_code": 412,
|
||||||
|
"message": "Destination already exists and overwrite is false",
|
||||||
|
}
|
||||||
|
elif e.response.status_code == 409:
|
||||||
|
logger.debug(
|
||||||
|
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status_code": 409,
|
||||||
|
"message": "Parent directory of destination doesn't exist",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"HTTP error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||||
|
)
|
||||||
|
raise e
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
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 DeckAssignedUser(BaseModel):
|
||||||
|
id: int
|
||||||
|
participant: DeckUser
|
||||||
|
cardId: int
|
||||||
|
type: int
|
||||||
|
|
||||||
|
|
||||||
|
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[Union[DeckUser, DeckAssignedUser]]] = 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
|
||||||
|
|
||||||
|
@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
|
||||||
|
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 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")
|
||||||
@@ -86,3 +86,23 @@ class DeleteResourceResponse(StatusResponse):
|
|||||||
items_deleted: Optional[int] = Field(
|
items_deleted: Optional[int] = Field(
|
||||||
None, description="Number of items deleted (for directories)"
|
None, description="Number of items deleted (for directories)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MoveResourceResponse(StatusResponse):
|
||||||
|
"""Response model for resource move/rename operations."""
|
||||||
|
|
||||||
|
source_path: str = Field(description="Original path of the resource")
|
||||||
|
destination_path: str = Field(description="New path of the resource")
|
||||||
|
overwrite: bool = Field(
|
||||||
|
description="Whether the destination was overwritten if it existed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CopyResourceResponse(StatusResponse):
|
||||||
|
"""Response model for resource copy operations."""
|
||||||
|
|
||||||
|
source_path: str = Field(description="Original path of the resource")
|
||||||
|
destination_path: str = Field(description="Destination path for the copy")
|
||||||
|
overwrite: bool = Field(
|
||||||
|
description="Whether the destination was overwritten if it existed"
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from .notes import configure_notes_tools
|
|||||||
from .tables import configure_tables_tools
|
from .tables import configure_tables_tools
|
||||||
from .webdav import configure_webdav_tools
|
from .webdav import configure_webdav_tools
|
||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
|
from .deck import configure_deck_tools
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"configure_calendar_tools",
|
"configure_calendar_tools",
|
||||||
|
"configure_contacts_tools",
|
||||||
|
"configure_deck_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_tables_tools",
|
"configure_tables_tools",
|
||||||
"configure_webdav_tools",
|
"configure_webdav_tools",
|
||||||
"configure_contacts_tools",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
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 (
|
||||||
|
CreateBoardResponse,
|
||||||
|
CreateStackResponse,
|
||||||
|
StackOperationResponse,
|
||||||
|
CreateCardResponse,
|
||||||
|
CardOperationResponse,
|
||||||
|
CreateLabelResponse,
|
||||||
|
LabelOperationResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
@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_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)
|
||||||
|
|
||||||
|
# Stack Tools
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
@@ -149,3 +149,67 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def nc_webdav_move_resource(
|
||||||
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
|
):
|
||||||
|
"""Move or rename a file or directory in NextCloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Full path of the file or directory to move
|
||||||
|
destination_path: New path for the file or directory
|
||||||
|
overwrite: Whether to overwrite the destination if it exists (default: False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Rename a file
|
||||||
|
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
||||||
|
|
||||||
|
# Move a file to another directory
|
||||||
|
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
||||||
|
|
||||||
|
# Move a directory
|
||||||
|
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
||||||
|
|
||||||
|
# Move and overwrite if destination exists
|
||||||
|
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
||||||
|
"""
|
||||||
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
|
return await client.webdav.move_resource(
|
||||||
|
source_path, destination_path, overwrite
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def nc_webdav_copy_resource(
|
||||||
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
|
):
|
||||||
|
"""Copy a file or directory in NextCloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Full path of the file or directory to copy
|
||||||
|
destination_path: Destination path for the copy
|
||||||
|
overwrite: Whether to overwrite the destination if it exists (default: False)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Copy a file
|
||||||
|
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
||||||
|
|
||||||
|
# Copy a file to another directory
|
||||||
|
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
||||||
|
|
||||||
|
# Copy a directory
|
||||||
|
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
||||||
|
|
||||||
|
# Copy and overwrite if destination exists
|
||||||
|
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
||||||
|
"""
|
||||||
|
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||||
|
return await client.webdav.copy_resource(
|
||||||
|
source_path, destination_path, overwrite
|
||||||
|
)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||||
|
|||||||
@@ -250,3 +250,149 @@ async def temporary_contact(nc_client: NextcloudClient, temporary_addressbook: s
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Unexpected error deleting temporary contact {contact_uid}: {e}"
|
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}")
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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_create_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. 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. 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 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 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}")
|
||||||
|
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 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 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}")
|
||||||
|
logger.info("Board structure and data verified successfully")
|
||||||
@@ -51,6 +51,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
|||||||
"nc_calendar_find_availability",
|
"nc_calendar_find_availability",
|
||||||
"nc_calendar_bulk_operations",
|
"nc_calendar_bulk_operations",
|
||||||
"nc_calendar_manage_calendar",
|
"nc_calendar_manage_calendar",
|
||||||
|
"deck_create_board",
|
||||||
]
|
]
|
||||||
|
|
||||||
for expected_tool in expected_tools:
|
for expected_tool in expected_tools:
|
||||||
@@ -83,7 +84,7 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
|||||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||||
|
|
||||||
# Verify expected resources
|
# Verify expected resources
|
||||||
expected_resources = ["nc://capabilities", "notes://settings"]
|
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||||
|
|
||||||
for expected_resource in expected_resources:
|
for expected_resource in expected_resources:
|
||||||
assert expected_resource in resource_uris, (
|
assert expected_resource in resource_uris, (
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user