Files
mcp-nextcloud/nextcloud_mcp_server/client/collectives.py
T
Chris CoutinhoandClaude Opus 4.6 52470ea713 fix: address PR review feedback (round 9)
- Fix emoji clearing bug: use _UNSET sentinel in update_collective so
  emoji=None sends {"emoji": null} instead of raising ValueError
- Move collectives_get_trashed_collectives to Read Tools section
- Remove redundant is_trash field from ListTrashedPagesResponse
- Add page lifecycle note to collectives_trash_page docstring
- Add unit test for clearing collective emoji via update_collective
- Add integration test for clearing collective emoji via MCP tool

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:19:53 +01:00

311 lines
10 KiB
Python

"""Client for Nextcloud Collectives app API (OCS)."""
import logging
from typing import Any
from nextcloud_mcp_server.client.base import BaseNextcloudClient
logger = logging.getLogger(__name__)
API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0"
_UNSET = object()
"""Sentinel to distinguish 'not provided' from an explicit None."""
class OCSError(Exception):
"""Error returned in the OCS response envelope."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"OCS error {status_code}: {message}")
class CollectivesClient(BaseNextcloudClient):
"""Client for Nextcloud Collectives app operations."""
app_name = "collectives"
_OCS_HEADERS: dict[str, str] = {
"OCS-APIRequest": "true",
"Accept": "application/json",
}
_OCS_HEADERS_JSON: dict[str, str] = {
**_OCS_HEADERS,
"Content-Type": "application/json",
}
def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any:
"""Unwrap OCS envelope, validating the status before returning data."""
ocs = response_json.get("ocs")
if ocs is None:
raise OCSError(500, "Response is not an OCS envelope")
meta = ocs.get("meta", {})
status_code = meta.get("statuscode", 200)
if status_code >= 400:
message = meta.get("message", "OCS error")
raise OCSError(status_code, message)
if "data" not in ocs:
raise OCSError(500, "OCS response missing 'data' field")
return ocs["data"]
# Collectives
async def get_collectives(self) -> list[dict[str, Any]]:
"""List all collectives the user has access to."""
response = await self._make_request(
"GET", f"{API_BASE}/collectives", headers=self._OCS_HEADERS
)
data = self._unwrap_ocs(response.json())
return data["collectives"]
async def create_collective(
self, name: str, emoji: str | None = None
) -> dict[str, Any]:
"""Create a new collective."""
json_data: dict[str, Any] = {"name": name}
if emoji is not None:
json_data["emoji"] = emoji
response = await self._make_request(
"POST",
f"{API_BASE}/collectives",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
async def update_collective(
self, collective_id: int, emoji: str | None | object = _UNSET
) -> dict[str, Any]:
"""Update a collective (emoji).
Pass emoji=None to clear the emoji. Omit emoji entirely to leave
it unchanged.
Raises:
ValueError: If no fields are provided to update.
"""
json_data: dict[str, Any] = {}
if emoji is not _UNSET:
json_data["emoji"] = emoji
if not json_data:
raise ValueError("At least one field must be provided to update")
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
async def trash_collective(self, collective_id: int) -> None:
"""Move a collective to trash (soft delete)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def delete_collective(self, collective_id: int) -> None:
"""Permanently delete a collective (must be trashed first).
This is irreversible. The collective must be in the trash before
calling this method.
"""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
# Trash (collectives)
async def get_trashed_collectives(self) -> list[dict[str, Any]]:
"""List trashed collectives."""
response = await self._make_request(
"GET", f"{API_BASE}/collectives/trash", headers=self._OCS_HEADERS
)
data = self._unwrap_ocs(response.json())
return data["collectives"]
async def restore_collective(self, collective_id: int) -> dict[str, Any]:
"""Restore a collective from trash."""
response = await self._make_request(
"PATCH",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["collective"]
# Pages
async def get_pages(self, collective_id: int) -> list[dict[str, Any]]:
"""List all pages in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
async def get_page(self, collective_id: int, page_id: int) -> dict[str, Any]:
"""Get a single page's metadata."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def create_page(
self, collective_id: int, parent_id: int, title: str
) -> dict[str, Any]:
"""Create a new page under a parent page."""
json_data = {"title": title}
response = await self._make_request(
"POST",
f"{API_BASE}/collectives/{collective_id}/pages/{parent_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def move_page(
self,
collective_id: int,
page_id: int,
parent_id: int | None = None,
title: str | None = None,
index: int = 0,
copy: bool = False,
) -> dict[str, Any]:
"""Move or copy a page within a collective."""
json_data: dict[str, Any] = {"index": index, "copy": copy}
if parent_id is not None:
json_data["parentId"] = parent_id
if title is not None:
json_data["title"] = title
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
async def trash_page(self, collective_id: int, page_id: int) -> None:
"""Move a page to trash (soft delete)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def set_page_emoji(
self, collective_id: int, page_id: int, emoji: str | None
) -> dict[str, Any]:
"""Set or clear the emoji on a page."""
# Sending {"emoji": null} intentionally clears the emoji on the server
json_data = {"emoji": emoji}
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/emoji",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["page"]
# Search
async def search_pages(
self, collective_id: int, query: str
) -> list[dict[str, Any]]:
"""Full-text search within a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/search",
params={"searchString": query},
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
# Tags
async def get_tags(self, collective_id: int) -> list[dict[str, Any]]:
"""List all tags in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/tags",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["tags"]
async def create_tag(
self, collective_id: int, name: str, color: str
) -> dict[str, Any]:
"""Create a new tag in a collective."""
json_data = {"name": name, "color": color}
response = await self._make_request(
"POST",
f"{API_BASE}/collectives/{collective_id}/tags",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["tag"]
async def assign_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
"""Assign a tag to a page."""
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
async def remove_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
"""Remove a tag from a page."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())
# Trash
async def get_trashed_pages(self, collective_id: int) -> list[dict[str, Any]]:
"""List trashed pages in a collective."""
response = await self._make_request(
"GET",
f"{API_BASE}/collectives/{collective_id}/pages/trash",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["pages"]
async def restore_page(self, collective_id: int, page_id: int) -> dict[str, Any]:
"""Restore a page from trash."""
response = await self._make_request(
"PATCH",
f"{API_BASE}/collectives/{collective_id}/pages/trash/{page_id}",
headers=self._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["page"]