From 85f8522085be55f8d257a227abc1b692910d98ec Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 15 Oct 2025 03:43:25 +0200 Subject: [PATCH] feat: Add Groups API client --- nextcloud_mcp_server/client/__init__.py | 2 + nextcloud_mcp_server/client/groups.py | 151 ++++++++++++++++++++++++ nextcloud_mcp_server/client/sharing.py | 18 ++- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 nextcloud_mcp_server/client/groups.py diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index eaba19f..c292b52 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -15,6 +15,7 @@ from ..controllers.notes_search import NotesSearchController from .calendar import CalendarClient from .contacts import ContactsClient from .deck import DeckClient +from .groups import GroupsClient from .notes import NotesClient from .sharing import SharingClient from .tables import TablesClient @@ -74,6 +75,7 @@ class NextcloudClient: self.contacts = ContactsClient(self._client, username) self.deck = DeckClient(self._client, username) self.users = UsersClient(self._client, username) + self.groups = GroupsClient(self._client, username) self.sharing = SharingClient(self._client, username) # Initialize controllers diff --git a/nextcloud_mcp_server/client/groups.py b/nextcloud_mcp_server/client/groups.py new file mode 100644 index 0000000..bf3e502 --- /dev/null +++ b/nextcloud_mcp_server/client/groups.py @@ -0,0 +1,151 @@ +"""Nextcloud Groups API client.""" + +import logging +from typing import List + +from .base import BaseNextcloudClient, retry_on_429 + +logger = logging.getLogger(__name__) + + +class GroupsClient(BaseNextcloudClient): + """Client for Nextcloud Groups API operations.""" + + @retry_on_429 + async def search_groups( + self, + search: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> List[str]: + """ + Search for groups on the Nextcloud server. + + Args: + search: Optional search string to filter groups + limit: Optional limit for number of results + offset: Optional offset for pagination + + Returns: + List of group IDs matching the search criteria + """ + 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 + + response = await self._client.get( + "/ocs/v2.php/cloud/groups", + params=params, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + data = response.json() + + groups = data["ocs"]["data"].get("groups", []) + return groups + + @retry_on_429 + async def create_group(self, groupid: str) -> None: + """ + Create a new group. + + Args: + groupid: The group ID to create + + Raises: + HTTPStatusError: If the request fails (e.g., group already exists) + """ + response = await self._client.post( + "/ocs/v2.php/cloud/groups", + data={"groupid": groupid}, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + logger.info(f"Created group: {groupid}") + + @retry_on_429 + async def delete_group(self, groupid: str) -> None: + """ + Delete a group. + + Args: + groupid: The group ID to delete + + Raises: + HTTPStatusError: If the request fails (e.g., group doesn't exist) + """ + response = await self._client.delete( + f"/ocs/v2.php/cloud/groups/{groupid}", + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + logger.info(f"Deleted group: {groupid}") + + @retry_on_429 + async def get_group_members(self, groupid: str) -> List[str]: + """ + Get members of a group. + + Args: + groupid: The group ID + + Returns: + List of usernames in the group + """ + response = await self._client.get( + f"/ocs/v2.php/cloud/groups/{groupid}", + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + data = response.json() + + users = data["ocs"]["data"].get("users", []) + return users + + @retry_on_429 + async def get_group_subadmins(self, groupid: str) -> List[str]: + """ + Get subadmins of a group. + + Args: + groupid: The group ID + + Returns: + List of usernames who are subadmins of the group + """ + response = await self._client.get( + f"/ocs/v2.php/cloud/groups/{groupid}/subadmins", + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + data = response.json() + + # The API returns data as a list or dict depending on results + subadmins_data = data["ocs"]["data"] + if isinstance(subadmins_data, list): + return subadmins_data + return [] + + @retry_on_429 + async def update_group_displayname(self, groupid: str, displayname: str) -> None: + """ + Update a group's display name. + + Args: + groupid: The group ID + displayname: The new display name + + Raises: + HTTPStatusError: If the request fails + """ + response = await self._client.put( + f"/ocs/v2.php/cloud/groups/{groupid}", + data={"key": "displayname", "value": displayname}, + headers={"OCS-APIRequest": "true", "Accept": "application/json"}, + ) + response.raise_for_status() + logger.info(f"Updated group {groupid} displayname to: {displayname}") diff --git a/nextcloud_mcp_server/client/sharing.py b/nextcloud_mcp_server/client/sharing.py index 29cdf5c..d20499a 100644 --- a/nextcloud_mcp_server/client/sharing.py +++ b/nextcloud_mcp_server/client/sharing.py @@ -53,12 +53,22 @@ class SharingClient(BaseNextcloudClient): response.raise_for_status() data = response.json() - if data["ocs"]["meta"]["statuscode"] not in (100, 200): - raise RuntimeError( - f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}" - ) + # OCS API v2 uses HTTP-style status codes (200 for success) + # OCS API v1 used custom codes (100 for success) + ocs_status = data["ocs"]["meta"]["statuscode"] + if ocs_status not in (100, 200): + ocs_message = data["ocs"]["meta"].get("message", "Unknown error") + raise RuntimeError(f"OCS API error (code {ocs_status}): {ocs_message}") share_data = data["ocs"]["data"] + + # Handle case where data might be an empty list on error + if not share_data or (isinstance(share_data, list) and len(share_data) == 0): + ocs_message = data["ocs"]["meta"].get("message", "Unknown error") + raise RuntimeError( + f"Share creation failed: {ocs_message} (status {ocs_status})" + ) + logger.info( f"Created share {share_data['id']}: {path} -> {share_with} " f"(type={share_type}, permissions={permissions})"