diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py
index b6879c6..f2cb17e 100644
--- a/nextcloud_mcp_server/client/__init__.py
+++ b/nextcloud_mcp_server/client/__init__.py
@@ -18,6 +18,7 @@ from .deck import DeckClient
from .notes import NotesClient
from .tables import TablesClient
from .webdav import WebDAVClient
+from .users import UsersClient
logger = logging.getLogger(__name__)
@@ -71,6 +72,7 @@ class NextcloudClient:
self.calendar = CalendarClient(self._client, username)
self.contacts = ContactsClient(self._client, username)
self.deck = DeckClient(self._client, username)
+ self.users = UsersClient(self._client, username)
# Initialize controllers
self._notes_search = NotesSearchController()
diff --git a/nextcloud_mcp_server/client/users.py b/nextcloud_mcp_server/client/users.py
new file mode 100644
index 0000000..41ca5c9
--- /dev/null
+++ b/nextcloud_mcp_server/client/users.py
@@ -0,0 +1,244 @@
+from typing import List, Optional, Dict
+from nextcloud_mcp_server.client.base import BaseNextcloudClient
+from nextcloud_mcp_server.models.users import UserDetails
+
+
+class UsersClient(BaseNextcloudClient):
+ """Client for Nextcloud User API operations."""
+
+ def _get_user_headers(
+ self, additional_headers: Optional[Dict[str, str]] = None
+ ) -> Dict[str, str]:
+ """Get standard headers required for User API calls."""
+ headers = {"OCS-APIRequest": "true"}
+ if additional_headers:
+ headers.update(additional_headers)
+ return headers
+
+ async def create_user(
+ self,
+ userid: str,
+ password: Optional[str] = None,
+ display_name: Optional[str] = None,
+ email: Optional[str] = None,
+ groups: Optional[List[str]] = None,
+ subadmin_groups: Optional[List[str]] = None,
+ quota: Optional[str] = None,
+ language: Optional[str] = None,
+ ) -> None:
+ """
+ Create a new user on the Nextcloud server.
+ """
+ data = {"userid": userid}
+ if password is not None:
+ data["password"] = password
+ if display_name is not None:
+ data["displayName"] = display_name
+ if email is not None:
+ data["email"] = email
+ if groups is not None:
+ for i, group in enumerate(groups):
+ data[f"groups[{i}]"] = group
+ if subadmin_groups is not None:
+ for i, group in enumerate(subadmin_groups):
+ data[f"subadmin[{i}]"] = group
+ if quota is not None:
+ data["quota"] = quota
+ if language is not None:
+ data["language"] = language
+
+ headers = self._get_user_headers()
+ await self._make_request(
+ "POST", "/ocs/v1.php/cloud/users", data=data, headers=headers
+ )
+
+ async def search_users(
+ self,
+ search: Optional[str] = None,
+ limit: Optional[int] = None,
+ offset: Optional[int] = None,
+ ) -> List[str]:
+ """
+ Retrieves a list of users from the Nextcloud server.
+ """
+ params = {}
+ if search is not None:
+ params["search"] = search
+ if limit is not None:
+ params["limit"] = limit
+ if offset is not None:
+ params["offset"] = offset
+
+ headers = self._get_user_headers()
+ response = await self._make_request(
+ "GET", "/ocs/v1.php/cloud/users", params=params, headers=headers
+ )
+ # The API returns XML, which is parsed into a dict.
+ # The user IDs are under ocs.data.users.element (can be a list or a single string)
+ data = response.json()["ocs"]["data"]
+ if "users" in data and "element" in data["users"]:
+ elements = data["users"]["element"]
+ if isinstance(elements, list):
+ return elements
+ elif isinstance(elements, str):
+ return [elements]
+ return []
+
+ async def get_user_details(self, userid: str) -> UserDetails:
+ """
+ Retrieves information about a single user.
+ """
+ headers = self._get_user_headers()
+ response = await self._make_request(
+ "GET", f"/ocs/v1.php/cloud/users/{userid}", headers=headers
+ )
+ return UserDetails(**response.json()["ocs"]["data"])
+
+ async def update_user_field(self, userid: str, key: str, value: str) -> None:
+ """
+ Edits attributes related to a user.
+ """
+ data = {"key": key, "value": value}
+ headers = self._get_user_headers()
+ await self._make_request(
+ "PUT", f"/ocs/v1.php/cloud/users/{userid}", data=data, headers=headers
+ )
+
+ async def get_editable_user_fields(self) -> List[str]:
+ """
+ Gets the list of editable data fields for a user.
+ """
+ headers = self._get_user_headers()
+ response = await self._make_request(
+ "GET", "/ocs/v1.php/cloud/user/fields", headers=headers
+ )
+ data = response.json()["ocs"]["data"]
+ if "element" in data:
+ elements = data["element"]
+ if isinstance(elements, list):
+ return elements
+ elif isinstance(elements, str):
+ return [elements]
+ return []
+
+ async def disable_user(self, userid: str) -> None:
+ """
+ Disables a user on the Nextcloud server.
+ """
+ headers = self._get_user_headers()
+ await self._make_request(
+ "PUT", f"/ocs/v1.php/cloud/users/{userid}/disable", headers=headers
+ )
+
+ async def enable_user(self, userid: str) -> None:
+ """
+ Enables a user on the Nextcloud server.
+ """
+ headers = self._get_user_headers()
+ await self._make_request(
+ "PUT", f"/ocs/v1.php/cloud/users/{userid}/enable", headers=headers
+ )
+
+ async def delete_user(self, userid: str) -> None:
+ """
+ Deletes a user from the Nextcloud server.
+ """
+ headers = self._get_user_headers()
+ await self._make_request(
+ "DELETE", f"/ocs/v1.php/cloud/users/{userid}", headers=headers
+ )
+
+ async def get_user_groups(self, userid: str) -> List[str]:
+ """
+ Retrieves a list of groups the specified user is a member of.
+ """
+ headers = self._get_user_headers()
+ response = await self._make_request(
+ "GET", f"/ocs/v1.php/cloud/users/{userid}/groups", headers=headers
+ )
+ data = response.json()["ocs"]["data"]
+ if "groups" in data and "element" in data["groups"]:
+ elements = data["groups"]["element"]
+ if isinstance(elements, list):
+ return elements
+ elif isinstance(elements, str):
+ return [elements]
+ return []
+
+ async def add_user_to_group(self, userid: str, groupid: str) -> None:
+ """
+ Adds the specified user to the specified group.
+ """
+ data = {"groupid": groupid}
+ headers = self._get_user_headers()
+ await self._make_request(
+ "POST",
+ f"/ocs/v1.php/cloud/users/{userid}/groups",
+ data=data,
+ headers=headers,
+ )
+
+ async def remove_user_from_group(self, userid: str, groupid: str) -> None:
+ """
+ Removes the specified user from the specified group.
+ """
+ data = {"groupid": groupid}
+ headers = self._get_user_headers()
+ await self._make_request(
+ "DELETE",
+ f"/ocs/v1.php/cloud/users/{userid}/groups",
+ data=data,
+ headers=headers,
+ )
+
+ async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None:
+ """
+ Makes a user the subadmin of a group.
+ """
+ data = {"groupid": groupid}
+ headers = self._get_user_headers()
+ await self._make_request(
+ "POST",
+ f"/ocs/v1.php/cloud/users/{userid}/subadmins",
+ data=data,
+ headers=headers,
+ )
+
+ async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None:
+ """
+ Removes the subadmin rights for the user specified from the group specified.
+ """
+ data = {"groupid": groupid}
+ headers = self._get_user_headers()
+ await self._make_request(
+ "DELETE",
+ f"/ocs/v1.php/cloud/users/{userid}/subadmins",
+ data=data,
+ headers=headers,
+ )
+
+ async def get_user_subadmin_groups(self, userid: str) -> List[str]:
+ """
+ Returns the groups in which the user is a subadmin.
+ """
+ headers = self._get_user_headers()
+ response = await self._make_request(
+ "GET", f"/ocs/v1.php/cloud/users/{userid}/subadmins", headers=headers
+ )
+ data = response.json()["ocs"]["data"]
+ if "element" in data:
+ elements = data["element"]
+ if isinstance(elements, list):
+ return elements
+ elif isinstance(elements, str):
+ return [elements]
+ return []
+
+ async def resend_welcome_email(self, userid: str) -> None:
+ """
+ Triggers the welcome email for this user again.
+ """
+ headers = self._get_user_headers()
+ await self._make_request(
+ "POST", f"/ocs/v1.php/cloud/users/{userid}/welcome", headers=headers
+ )
diff --git a/nextcloud_mcp_server/models/users.py b/nextcloud_mcp_server/models/users.py
new file mode 100644
index 0000000..de3070b
--- /dev/null
+++ b/nextcloud_mcp_server/models/users.py
@@ -0,0 +1,38 @@
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+
+class User(BaseModel):
+ """Model for creating a new user."""
+
+ userid: str
+ password: Optional[str] = None
+ displayName: Optional[str] = None
+ email: Optional[str] = None
+ groups: Optional[List[str]] = Field(default_factory=list)
+ subadmin: Optional[List[str]] = Field(default_factory=list)
+ quota: Optional[str] = None
+ language: Optional[str] = None
+
+
+class UserDetails(BaseModel):
+ """Model for retrieving detailed user information."""
+
+ enabled: bool
+ id: str
+ quota: str
+ email: str
+ displayname: str = Field(
+ alias="display-name"
+ ) # Handle both displayname and display-name
+ phone: Optional[str] = None
+ address: Optional[str] = None
+ website: Optional[str] = None
+ twitter: Optional[str] = None
+ groups: Optional[List[str]] = Field(default_factory=list)
+
+
+class Group(BaseModel):
+ """Model for a user group."""
+
+ id: str
diff --git a/tests/integration/test_deck_advanced_features.py b/tests/integration/test_deck_advanced_features.py
new file mode 100644
index 0000000..3fa682d
--- /dev/null
+++ b/tests/integration/test_deck_advanced_features.py
@@ -0,0 +1,569 @@
+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
+
+
+# Stack MCP Tools Tests
+async def test_deck_stack_mcp_tools(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
+):
+ """Test complete deck stack operations via MCP tools."""
+ board_id = temporary_board["id"]
+ stack_title = f"MCP Test Stack {uuid.uuid4().hex[:8]}"
+ stack_order = 1
+
+ # 1. Create stack via MCP tool
+ logger.info(f"Creating stack via MCP: {stack_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_stack",
+ {"board_id": board_id, "title": stack_title, "order": stack_order},
+ )
+
+ assert create_result.isError is False, (
+ f"MCP stack creation failed: {create_result.content}"
+ )
+ created_stack_response = json.loads(create_result.content[0].text)
+ stack_id = created_stack_response["id"]
+ assert created_stack_response["title"] == stack_title
+ assert created_stack_response["order"] == stack_order
+ logger.info(f"Stack created via MCP with ID: {stack_id}")
+
+ try:
+ # 2. Get stack via MCP resource
+ logger.info(f"Getting stack via MCP resource: {stack_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_stack_response = json.loads(get_result.contents[0].text)
+ assert get_stack_response["title"] == stack_title
+ logger.info("Stack retrieved via MCP resource successfully")
+
+ # 3. Update stack via MCP tool
+ updated_title = f"Updated {stack_title}"
+ updated_order = 2
+ logger.info(f"Updating stack via MCP tool: {stack_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_stack",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": updated_title,
+ "order": updated_order,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP stack update failed: {update_result.content}"
+ )
+ logger.info("Stack updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ 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("Stack update verified via direct client")
+
+ # 5. List stacks via MCP resource
+ logger.info("Listing stacks via MCP resource")
+ list_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks"
+ )
+
+ assert len(list_result.contents) == 1, "Expected exactly one content item"
+ stacks_data = json.loads(list_result.contents[0].text)
+ assert isinstance(stacks_data, list)
+
+ # Verify our stack is in the list
+ stack_ids = [stack["id"] for stack in stacks_data]
+ assert stack_id in stack_ids, "Updated stack not found in list"
+ logger.info(f"Stack {stack_id} found in stacks list")
+
+ # 6. Read stack via MCP resource
+ logger.info(f"Reading stack via MCP resource: {stack_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
+ )
+ read_stack_data = json.loads(read_result.contents[0].text)
+ assert read_stack_data["title"] == updated_title
+ logger.info("Stack read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_stack(board_id, stack_id)
+ logger.info(f"Cleaned up stack ID: {stack_id}")
+
+
+# Card MCP Tools Tests
+async def test_deck_card_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_stack: tuple,
+):
+ """Test complete deck card operations via MCP tools."""
+ board_data, stack_data = temporary_board_with_stack
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_title = f"MCP Test Card {uuid.uuid4().hex[:8]}"
+ card_description = f"Test description for {card_title}"
+
+ # 1. Create card via MCP tool
+ logger.info(f"Creating card via MCP: {card_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "title": card_title,
+ "description": card_description,
+ "type": "plain",
+ "order": 1,
+ },
+ )
+
+ assert create_result.isError is False, (
+ f"MCP card creation failed: {create_result.content}"
+ )
+ created_card_response = json.loads(create_result.content[0].text)
+ card_id = created_card_response["id"]
+ assert created_card_response["title"] == card_title
+ assert created_card_response["description"] == card_description
+ logger.info(f"Card created via MCP with ID: {card_id}")
+
+ try:
+ # 2. Get card via MCP resource
+ logger.info(f"Getting card via MCP resource: {card_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_card_response = json.loads(get_result.contents[0].text)
+ assert get_card_response["title"] == card_title
+ logger.info("Card retrieved via MCP resource successfully")
+
+ # 3. Update card via MCP tool
+ updated_title = f"Updated {card_title}"
+ updated_description = f"Updated description for {card_title}"
+ logger.info(f"Updating card via MCP tool: {card_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "title": updated_title,
+ "description": updated_description,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP card update failed: {update_result.content}"
+ )
+ logger.info("Card updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ 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("Card update verified via direct client")
+
+ # 5. Archive/unarchive card via MCP tools
+ logger.info(f"Archiving card via MCP tool: {card_id}")
+ archive_result = await nc_mcp_client.call_tool(
+ "deck_archive_card",
+ {"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
+ )
+
+ assert archive_result.isError is False, (
+ f"MCP card archive failed: {archive_result.content}"
+ )
+ logger.info("Card archived via MCP tool successfully")
+
+ logger.info(f"Unarchiving card via MCP tool: {card_id}")
+ unarchive_result = await nc_mcp_client.call_tool(
+ "deck_unarchive_card",
+ {"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
+ )
+
+ assert unarchive_result.isError is False, (
+ f"MCP card unarchive failed: {unarchive_result.content}"
+ )
+ logger.info("Card unarchived via MCP tool successfully")
+
+ # 6. Move card to different position via MCP tool
+ logger.info(f"Reordering card via MCP tool: {card_id}")
+ reorder_result = await nc_mcp_client.call_tool(
+ "deck_reorder_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "order": 10,
+ "target_stack_id": stack_id,
+ },
+ )
+
+ assert reorder_result.isError is False, (
+ f"MCP card reorder failed: {reorder_result.content}"
+ )
+ logger.info("Card reordered via MCP tool successfully")
+
+ # 7. Read card via MCP resource
+ logger.info(f"Reading card via MCP resource: {card_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
+ )
+ read_card_data = json.loads(read_result.contents[0].text)
+ assert read_card_data["title"] == updated_title
+ logger.info("Card read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_card(board_id, stack_id, card_id)
+ logger.info(f"Cleaned up card ID: {card_id}")
+
+
+# Label MCP Tools Tests
+async def test_deck_label_mcp_tools(
+ nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
+):
+ """Test complete deck label operations via MCP tools."""
+ board_id = temporary_board["id"]
+ label_title = f"MCP Test Label {uuid.uuid4().hex[:8]}"
+ label_color = "FF0000" # Red
+
+ # 1. Create label via MCP tool
+ logger.info(f"Creating label via MCP: {label_title}")
+ create_result = await nc_mcp_client.call_tool(
+ "deck_create_label",
+ {"board_id": board_id, "title": label_title, "color": label_color},
+ )
+
+ assert create_result.isError is False, (
+ f"MCP label creation failed: {create_result.content}"
+ )
+ created_label_response = json.loads(create_result.content[0].text)
+ label_id = created_label_response["id"]
+ assert created_label_response["title"] == label_title
+ assert created_label_response["color"] == label_color
+ logger.info(f"Label created via MCP with ID: {label_id}")
+
+ try:
+ # 2. Get label via MCP resource
+ logger.info(f"Getting label via MCP resource: {label_id}")
+ get_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels/{label_id}"
+ )
+
+ assert len(get_result.contents) == 1, "Expected exactly one content item"
+ get_label_response = json.loads(get_result.contents[0].text)
+ assert get_label_response["title"] == label_title
+ logger.info("Label retrieved via MCP resource successfully")
+
+ # 3. Update label via MCP tool
+ updated_title = f"Updated {label_title}"
+ updated_color = "00FF00" # Green
+ logger.info(f"Updating label via MCP tool: {label_id}")
+ update_result = await nc_mcp_client.call_tool(
+ "deck_update_label",
+ {
+ "board_id": board_id,
+ "label_id": label_id,
+ "title": updated_title,
+ "color": updated_color,
+ },
+ )
+
+ assert update_result.isError is False, (
+ f"MCP label update failed: {update_result.content}"
+ )
+ logger.info("Label updated via MCP tool successfully")
+
+ # 4. Verify update via direct client
+ 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("Label update verified via direct client")
+
+ # 5. Read label via MCP resource
+ logger.info(f"Reading label via MCP resource: {label_id}")
+ read_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels/{label_id}"
+ )
+ read_label_data = json.loads(read_result.contents[0].text)
+ assert read_label_data["title"] == updated_title
+ logger.info("Label read via MCP resource successfully")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_label(board_id, label_id)
+ logger.info(f"Cleaned up label ID: {label_id}")
+
+
+# Label-Card Assignment Tests
+async def test_deck_card_label_assignment_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_card: tuple,
+):
+ """Test card-label assignment operations via MCP tools."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_id = card_data["id"]
+
+ # Create a label for assignment
+ label = await nc_client.deck.create_label(
+ board_id, "Assignment Test Label", "0000FF"
+ )
+ label_id = label.id
+
+ try:
+ # 1. Assign label to card via MCP tool
+ logger.info(f"Assigning label {label_id} to card {card_id} via MCP")
+ assign_result = await nc_mcp_client.call_tool(
+ "deck_assign_label_to_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "label_id": label_id,
+ },
+ )
+
+ assert assign_result.isError is False, (
+ f"MCP label assignment failed: {assign_result.content}"
+ )
+ logger.info("Label assigned to card via MCP tool successfully")
+
+ # 2. Verify assignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.labels:
+ label_ids = [label.id for label in card.labels]
+ assert label_id in label_ids, "Label not found in card labels"
+ logger.info("Label assignment verified via direct client")
+
+ # 3. Remove label from card via MCP tool
+ logger.info(f"Removing label {label_id} from card {card_id} via MCP")
+ remove_result = await nc_mcp_client.call_tool(
+ "deck_remove_label_from_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "label_id": label_id,
+ },
+ )
+
+ assert remove_result.isError is False, (
+ f"MCP label removal failed: {remove_result.content}"
+ )
+ logger.info("Label removed from card via MCP tool successfully")
+
+ # 4. Verify removal via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.labels:
+ label_ids = [label.id for label in card.labels]
+ assert label_id not in label_ids, (
+ "Label still found in card labels after removal"
+ )
+ logger.info("Label removal verified via direct client")
+
+ finally:
+ # Clean up
+ await nc_client.deck.delete_label(board_id, label_id)
+ logger.info(f"Cleaned up label ID: {label_id}")
+
+
+# User Assignment Tests
+async def test_deck_card_user_assignment_mcp_tools(
+ nc_mcp_client: ClientSession,
+ nc_client: NextcloudClient,
+ temporary_board_with_card: tuple,
+):
+ """Test card-user assignment operations via MCP tools."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+ card_id = card_data["id"]
+
+ # Use the current user ID (admin in most test environments)
+ user_id = "admin"
+
+ # 1. Assign user to card via MCP tool
+ logger.info(f"Assigning user {user_id} to card {card_id} via MCP")
+ assign_result = await nc_mcp_client.call_tool(
+ "deck_assign_user_to_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "user_id": user_id,
+ },
+ )
+
+ assert assign_result.isError is False, (
+ f"MCP user assignment failed: {assign_result.content}"
+ )
+ logger.info("User assigned to card via MCP tool successfully")
+
+ # 2. Verify assignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.assignedUsers:
+ user_ids = []
+ for user in card.assignedUsers:
+ if hasattr(user, "participant"):
+ # It's a DeckAssignedUser with participant
+ user_ids.append(user.participant.uid)
+ elif hasattr(user, "uid"):
+ # It's a direct DeckUser
+ user_ids.append(user.uid)
+ assert user_id in user_ids, "User not found in card assigned users"
+ logger.info("User assignment verified via direct client")
+
+ # 3. Unassign user from card via MCP tool
+ logger.info(f"Unassigning user {user_id} from card {card_id} via MCP")
+ unassign_result = await nc_mcp_client.call_tool(
+ "deck_unassign_user_from_card",
+ {
+ "board_id": board_id,
+ "stack_id": stack_id,
+ "card_id": card_id,
+ "user_id": user_id,
+ },
+ )
+
+ assert unassign_result.isError is False, (
+ f"MCP user unassignment failed: {unassign_result.content}"
+ )
+ logger.info("User unassigned from card via MCP tool successfully")
+
+ # 4. Verify unassignment via direct client
+ card = await nc_client.deck.get_card(board_id, stack_id, card_id)
+ if card.assignedUsers:
+ user_ids = []
+ for user in card.assignedUsers:
+ if hasattr(user, "participant"):
+ # It's a DeckAssignedUser with participant
+ user_ids.append(user.participant.uid)
+ elif hasattr(user, "uid"):
+ # It's a direct DeckUser
+ user_ids.append(user.uid)
+ assert user_id not in user_ids, (
+ "User still found in card assigned users after removal"
+ )
+ logger.info("User unassignment verified via direct client")
+
+
+# Error handling tests
+async def test_deck_mcp_tools_error_handling(nc_mcp_client: ClientSession):
+ """Test error handling for deck MCP tools with invalid parameters."""
+ non_existent_id = 999999999
+
+ # Test stack operations with non-existent board
+ stack_result = await nc_mcp_client.call_tool(
+ "deck_create_stack",
+ {"board_id": non_existent_id, "title": "Should Fail", "order": 1},
+ )
+ assert stack_result.isError is True, (
+ "Expected error for stack creation on non-existent board"
+ )
+
+ # Test card operations with non-existent IDs
+ card_result = await nc_mcp_client.call_tool(
+ "deck_create_card",
+ {
+ "board_id": non_existent_id,
+ "stack_id": non_existent_id,
+ "title": "Should Fail",
+ "type": "plain",
+ },
+ )
+ assert card_result.isError is True, (
+ "Expected error for card creation with non-existent IDs"
+ )
+
+ # Test label operations with non-existent board
+ label_result = await nc_mcp_client.call_tool(
+ "deck_create_label",
+ {"board_id": non_existent_id, "title": "Should Fail", "color": "FF0000"},
+ )
+ assert label_result.isError is True, (
+ "Expected error for label creation on non-existent board"
+ )
+
+ logger.info("Error handling tests passed for deck MCP tools")
+
+
+# Resource template tests
+async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession):
+ """Test deck MCP resource templates are properly registered."""
+ templates = await nc_mcp_client.list_resource_templates()
+ template_uris = [template.uriTemplate for template in templates.resourceTemplates]
+
+ expected_templates = [
+ "nc://Deck/boards/{board_id}/stacks/{stack_id}",
+ "nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
+ "nc://Deck/boards/{board_id}/labels/{label_id}",
+ ]
+
+ for expected_template in expected_templates:
+ assert expected_template in template_uris, (
+ f"Expected template '{expected_template}' not found"
+ )
+ logger.info(f"Found expected deck resource template: {expected_template}")
+
+
+# Listing resource tests
+async def test_deck_mcp_listing_resources(
+ nc_mcp_client: ClientSession, temporary_board_with_card: tuple
+):
+ """Test deck MCP listing resources for stacks and cards."""
+ board_data, stack_data, card_data = temporary_board_with_card
+ board_id = board_data["id"]
+ stack_id = stack_data["id"]
+
+ # 1. Test listing stacks resource
+ logger.info(f"Reading stacks list via MCP resource for board {board_id}")
+ stacks_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks"
+ )
+ stacks_resource_data = json.loads(stacks_resource_result.contents[0].text)
+ assert isinstance(stacks_resource_data, list)
+
+ # Verify our stack is in the resource list
+ stack_ids = [stack["id"] for stack in stacks_resource_data]
+ assert stack_id in stack_ids, "Stack not found in stacks resource list"
+ logger.info("Stack found in stacks resource list")
+
+ # 2. Test listing cards resource
+ logger.info(f"Reading cards list via MCP resource for stack {stack_id}")
+ cards_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards"
+ )
+ cards_resource_data = json.loads(cards_resource_result.contents[0].text)
+ assert isinstance(cards_resource_data, list)
+
+ # Verify our card is in the resource list
+ card_ids = [card["id"] for card in cards_resource_data]
+ assert card_data["id"] in card_ids, "Card not found in cards resource list"
+ logger.info("Card found in cards resource list")
+
+ # 3. Test listing labels resource
+ logger.info(f"Reading labels list via MCP resource for board {board_id}")
+ labels_resource_result = await nc_mcp_client.read_resource(
+ f"nc://Deck/boards/{board_id}/labels"
+ )
+ labels_resource_data = json.loads(labels_resource_result.contents[0].text)
+ assert isinstance(labels_resource_data, list)
+ logger.info("Labels resource read successfully")
diff --git a/tests/integration/test_users_api.py b/tests/integration/test_users_api.py
new file mode 100644
index 0000000..3be5ee8
--- /dev/null
+++ b/tests/integration/test_users_api.py
@@ -0,0 +1,126 @@
+import pytest
+from nextcloud_mcp_server.client import NextcloudClient
+
+
+@pytest.mark.asyncio
+async def test_create_and_delete_user(nc_client: NextcloudClient):
+ userid = "testuser1"
+ password = "testpassword1"
+ display_name = "Test User One"
+ email = "test1@example.com"
+
+ # Create user
+ await nc_client.users.create_user(
+ userid=userid,
+ password=password,
+ display_name=display_name,
+ email=email,
+ )
+
+ # Verify user exists
+ users = await nc_client.users.search_users(search=userid)
+ assert userid in users
+
+ user_details = await nc_client.users.get_user_details(userid)
+ assert user_details.id == userid
+ assert user_details.displayname == display_name
+ assert user_details.email == email
+
+ # Delete user
+ await nc_client.users.delete_user(userid)
+
+ # Verify user is deleted
+ users = await nc_client.users.search_users(search=userid)
+ assert userid not in users
+
+
+@pytest.mark.asyncio
+async def test_update_user_field(nc_client: NextcloudClient):
+ userid = "testuser2"
+ password = "testpassword2"
+ display_name = "Test User Two"
+ email = "test2@example.com"
+
+ await nc_client.users.create_user(
+ userid=userid,
+ password=password,
+ display_name=display_name,
+ email=email,
+ )
+
+ new_email = "new.test2@example.com"
+ await nc_client.users.update_user_field(userid, "email", new_email)
+
+ user_details = await nc_client.users.get_user_details(userid)
+ assert user_details.email == new_email
+
+ await nc_client.users.delete_user(userid)
+
+
+@pytest.mark.asyncio
+async def test_user_groups(nc_client: NextcloudClient):
+ userid = "testuser3"
+ password = "testpassword3"
+ groupid = "testgroup"
+
+ await nc_client.users.create_user(userid=userid, password=password)
+
+ # Add user to group
+ await nc_client.users.add_user_to_group(userid, groupid)
+ groups = await nc_client.users.get_user_groups(userid)
+ assert groupid in groups
+
+ # Remove user from group
+ await nc_client.users.remove_user_from_group(userid, groupid)
+ groups = await nc_client.users.get_user_groups(userid)
+ assert groupid not in groups
+
+ await nc_client.users.delete_user(userid)
+
+
+@pytest.mark.asyncio
+async def test_user_subadmins(nc_client: NextcloudClient):
+ userid = "testuser4"
+ password = "testpassword4"
+ groupid = "subadmingroup"
+
+ await nc_client.users.create_user(userid=userid, password=password)
+
+ # Promote to subadmin
+ await nc_client.users.promote_user_to_subadmin(userid, groupid)
+ subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
+ assert groupid in subadmin_groups
+
+ # Demote from subadmin
+ await nc_client.users.demote_user_from_subadmin(userid, groupid)
+ subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
+ assert groupid not in subadmin_groups
+
+ await nc_client.users.delete_user(userid)
+
+
+@pytest.mark.asyncio
+async def test_disable_enable_user(nc_client: NextcloudClient):
+ userid = "testuser5"
+ password = "testpassword5"
+
+ await nc_client.users.create_user(userid=userid, password=password)
+
+ # Disable user
+ await nc_client.users.disable_user(userid)
+ user_details = await nc_client.users.get_user_details(userid)
+ assert not user_details.enabled
+
+ # Enable user
+ await nc_client.users.enable_user(userid)
+ user_details = await nc_client.users.get_user_details(userid)
+ assert user_details.enabled
+
+ await nc_client.users.delete_user(userid)
+
+
+@pytest.mark.asyncio
+async def test_get_editable_user_fields(nc_client: NextcloudClient):
+ editable_fields = await nc_client.users.get_editable_user_fields()
+ assert "displayname" in editable_fields
+ assert "email" in editable_fields
diff --git a/user-api.rst b/user-api.rst
new file mode 100644
index 0000000..183e7b3
--- /dev/null
+++ b/user-api.rst
@@ -0,0 +1,674 @@
+=========================
+Instruction set for users
+=========================
+
+Add a new user
+--------------
+
+Create a new user on the Nextcloud server. Authentication is done by sending a
+basic HTTP authentication header.
+
+**Syntax: ocs/v1.php/cloud/users**
+
+* HTTP method: POST
+* POST argument: userid - string, the required username for the new user
+* POST argument: password - string, the password for the new user, leave empty to send welcome mail
+* POST argument: displayName - string, the display name for the new user
+* POST argument: email - string, the email for the new user, required if password empty
+* POST argument: groups - array, the groups for the new user
+* POST argument: subadmin - array, the groups in which the new user is subadmin
+* POST argument: quota - string, quota for the new user
+* POST argument: language - string, language for the new user
+
+Status codes:
+
+* 101 - invalid argument
+* 102 - user already exists
+* 103 - cannot create sub-admins for admin group
+* 104 - group does not exist
+* 105 - insufficient privileges for group
+* 106 - no group specified (required for sub-admins)
+* 107 - hint exceptions
+* 108 - an email address is required, to send a password link to the user.
+* 109 - sub-admin group does not exist
+* 110 - required email address was not provided
+* 111 - could not create non-existing user ID
+
+Example
+^^^^^^^
+::
+
+ $ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true"
+
+* Creates the user ``Frank`` with password ``frankspassword``
+* optionally groups can be specified by one or more ``groups[]`` query parameters:
+ ``URL -d groups[]="admin" -D groups[]="Team1"``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+
+
+
+
+
+Search/get users
+----------------
+
+Retrieves a list of users from the Nextcloud server. Authentication is done by
+sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users**
+
+* HTTP method: GET
+* url arguments: search - string, optional search string
+* url arguments: limit - int, optional limit value
+* url arguments: offset - int, optional offset value
+
+Status codes:
+
+* 100 - successful
+
+Example
+^^^^^^^
+::
+
+ $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true"
+
+* Returns list of users matching the search string.
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+ Frank
+
+
+
+
+Get data of a single user
+-------------------------
+
+Retrieves information about a single user. Authentication is done by sending a
+Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}**
+
+* HTTP method: GET
+
+Status codes:
+
+* 100 - successful
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
+
+* Returns information on the user ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+ true
+ Frank
+ 0
+ frank@example.org
+ Frank K.
+ Frank K.
+ 0123 / 456 789
+ Foobar 12, 12345 Town
+ https://nextcloud.com
+ Nextcloud
+
+ group1
+ group2
+
+
+
+
+Edit data of a single user
+--------------------------
+
+Edits attributes related to a user. Users are able to edit email, displayname
+and password; admins can also edit the quota value. Further restrictions may apply,
+check the `List of editable data fields`_ endpoint. Authentication
+is done by sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}**
+
+* HTTP method: PUT
+* PUT argument: key, the field to edit:
+
+ + email
+ + quota
+ + displayname
+ + display (**deprecated** use `displayname` instead)
+ + phone
+ + address
+ + website
+ + twitter
+ + password
+
+* PUT argument: value, the new value for the field
+
+Status codes:
+
+* 101 - invalid argument
+* 107 - password policy (hint exception)
+* 112 - Setting the password is not supported by the users backend
+* 113 - editing field not allowed / field doesn’t exist
+
+Examples
+^^^^^^^^
+
+::
+
+ $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true"
+
+* Updates the email address for the user ``Frank``
+
+::
+
+ $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true"
+
+* Updates the quota for the user ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+.. _editable_field_list:
+
+List of editable data fields
+----------------------------
+
+Edits attributes related to a user. Users are able to edit email, displayname
+and password; admins can also edit the quota value. Authentication is done by
+sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/user/fields**
+
+* HTTP method: GET
+
+Status codes:
+
+* 100 - successful
+
+Examples
+^^^^^^^^
+
+::
+
+ $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true"
+
+* Gets the list of fields
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+ OK
+
+
+ displayname
+ email
+ phone
+ address
+ website
+ twitter
+
+
+
+
+Disable a user
+--------------
+
+Disables a user on the Nextcloud server so that the user cannot login anymore.
+Authentication is done by sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/disable**
+
+* HTTP method: PUT
+
+Statuscodes:
+
+* 100 - successful
+* 101 - failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true"
+
+* Disables the user ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+
+
+
+
+
+Enable a user
+-------------
+
+Enables a user on the Nextcloud server so that the user can login again.
+Authentication is done by sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/enable**
+
+* HTTP method: PUT
+
+Statuscodes:
+
+* 100 - successful
+* 101 - failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true"
+
+* Enables the user ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+
+
+
+
+
+Delete a user
+-------------
+
+Deletes a user from the Nextcloud server. Authentication is done by sending a
+Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}**
+
+* HTTP method: DELETE
+
+Statuscodes:
+
+* 100 - successful
+* 101 - failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
+
+* Deletes the user ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+Get user's groups
+-----------------
+
+Retrieves a list of groups the specified user is a member of. Authentication is
+done by sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
+
+* HTTP method: GET
+
+Status codes:
+
+* 100 - successful
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true"
+
+* Retrieves a list of groups of which ``Frank`` is a member
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+ admin
+ group1
+
+
+
+
+Add user to group
+-----------------
+
+Adds the specified user to the specified group. Authentication is done by
+sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
+
+* HTTP method: POST
+* POST argument: groupid, string - the group to add the user to
+
+Status codes:
+
+* 100 - successful
+* 101 - no group specified
+* 102 - group does not exist
+* 103 - user does not exist
+* 104 - insufficient privileges
+* 105 - failed to add user to group
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
+
+* Adds the user ``Frank`` to the group ``newgroup``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+Remove user from group
+----------------------
+
+Removes the specified user from the specified group. Authentication is done by
+sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
+
+* HTTP method: DELETE
+* DELETE argument: groupid, string - the group to remove the user from
+
+Status codes:
+
+* 100 - successful
+* 101 - no group specified
+* 102 - group does not exist
+* 103 - user does not exist
+* 104 - insufficient privileges
+* 105 - failed to remove user from group
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
+
+* Removes the user ``Frank`` from the group ``newgroup``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+Promote user to subadmin
+------------------------
+
+Makes a user the subadmin of a group. Authentication is done by sending a Basic
+HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
+
+* HTTP method: POST
+* POST argument: groupid, string - the group of which to make the user a
+ subadmin
+
+Status codes:
+
+* 100 - successful
+* 101 - user does not exist
+* 102 - group does not exist
+* 103 - unknown failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true"
+
+* Makes the user ``Frank`` a subadmin of the ``group`` group
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+Demote user from subadmin
+-------------------------
+
+Removes the subadmin rights for the user specified from the group specified.
+Authentication is done by sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
+
+* HTTP method: DELETE
+* DELETE argument: groupid, string - the group from which to remove the user's
+ subadmin rights
+
+Status codes:
+
+* 100 - successful
+* 101 - user does not exist
+* 102 - user is not a subadmin of the group / group does not exist
+* 103 - unknown failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true"
+
+* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ 100
+ ok
+
+
+
+
+Get user's subadmin groups
+--------------------------
+
+Returns the groups in which the user is a subadmin. Authentication is done by
+sending a Basic HTTP Authorization header.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
+
+* HTTP method: GET
+
+Status codes:
+
+* 100 - successful
+* 101 - user does not exist
+* 102 - unknown failure
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true"
+
+* Returns the groups of which ``Frank`` is a subadmin
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+
+
+
+ testgroup
+
+
+
+Resend the welcome email
+------------------------
+
+The request to this endpoint triggers the welcome email for this user again.
+
+**Syntax: ocs/v1.php/cloud/users/{userid}/welcome**
+
+* HTTP method: POST
+
+Status codes:
+
+* 100 - successful
+* 101 - email address not available
+* 102 - sending email failed
+
+Example
+^^^^^^^
+
+::
+
+ $ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true"
+
+* Sends the welcome email to ``Frank``
+
+XML output
+^^^^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+ ok
+ 100
+
+
+
+