fix: address PR review feedback for Collectives support

- Validate OCS envelope status before unwrapping data (raise OCSError on
  statuscode >= 400)
- Fix test data: filePath should be "" for root-level pages, not filename
- Catch specific exceptions (HTTPStatusError, OSError) instead of bare
  Exception in WebDAV content fetch, include error in log message
- Return updated resource data from update_collective, move_page, and
  set_page_emoji instead of discarding API responses
- Fix create_page docstring to mention collectivePath/filePath/fileName
- Remove unused additional_headers parameter from _get_ocs_headers
- Add unit test for OCS error status validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-25 08:07:37 +01:00
co-authored by Claude Opus 4.6
parent 32f5a0fe52
commit e5ad625a66
3 changed files with 66 additions and 25 deletions
+22 -9
View File
@@ -1,33 +1,46 @@
"""Client for Nextcloud Collectives app API (OCS).""" """Client for Nextcloud Collectives app API (OCS)."""
import logging
from typing import Any from typing import Any
from nextcloud_mcp_server.client.base import BaseNextcloudClient from nextcloud_mcp_server.client.base import BaseNextcloudClient
logger = logging.getLogger(__name__)
API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0" API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0"
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): class CollectivesClient(BaseNextcloudClient):
"""Client for Nextcloud Collectives app operations.""" """Client for Nextcloud Collectives app operations."""
app_name = "collectives" app_name = "collectives"
def _get_ocs_headers( def _get_ocs_headers(self) -> dict[str, str]:
self, additional_headers: dict[str, str] | None = None
) -> dict[str, str]:
"""Get standard headers required for OCS API calls.""" """Get standard headers required for OCS API calls."""
headers = { return {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
} }
if additional_headers:
headers.update(additional_headers)
return headers
def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any: def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any:
"""Unwrap OCS envelope, returning the data payload.""" """Unwrap OCS envelope, validating the status before returning data."""
return response_json["ocs"]["data"] ocs = response_json["ocs"]
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)
return ocs["data"]
# Collectives # Collectives
+20 -14
View File
@@ -2,6 +2,7 @@
import logging import logging
from httpx import HTTPStatusError
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
@@ -80,8 +81,8 @@ def configure_collectives_tools(mcp: FastMCP):
"""Get a page's metadata and markdown content from a Nextcloud Collective. """Get a page's metadata and markdown content from a Nextcloud Collective.
Content is fetched via WebDAV using the page's file path. To update Content is fetched via WebDAV using the page's file path. To update
page content, use the nc_webdav_write_file tool with the path from page content, use the nc_webdav_write_file tool with the path
the page's collectivePath/filePath fields. collectivePath/filePath/fileName (omit filePath for root-level pages).
Args: Args:
collective_id: ID of the collective collective_id: ID of the collective
@@ -104,10 +105,11 @@ def configure_collectives_tools(mcp: FastMCP):
try: try:
file_bytes, _ = await client.webdav.read_file(webdav_path) file_bytes, _ = await client.webdav.read_file(webdav_path)
content = file_bytes.decode("utf-8") content = file_bytes.decode("utf-8")
except Exception: except (HTTPStatusError, OSError) as e:
logger.warning( logger.warning(
"Failed to read page content via WebDAV: %s", "Failed to read page content via WebDAV: %s: %s",
webdav_path, webdav_path,
e,
) )
return GetPageResponse(page=page, content=content) return GetPageResponse(page=page, content=content)
@@ -217,11 +219,12 @@ def configure_collectives_tools(mcp: FastMCP):
emoji: New emoji for the collective emoji: New emoji for the collective
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.update_collective(collective_id, emoji) raw = await client.collectives.update_collective(collective_id, emoji)
collective = Collective(**raw)
return CollectiveOperationResponse( return CollectiveOperationResponse(
collective_id=collective_id, collective_id=collective.id,
status_code=200, status_code=200,
message="Collective updated", message=f"Collective updated (emoji: {collective.emoji})",
) )
@mcp.tool( @mcp.tool(
@@ -236,7 +239,8 @@ def configure_collectives_tools(mcp: FastMCP):
"""Create a new page in a Nextcloud Collective. """Create a new page in a Nextcloud Collective.
Pages are created as empty markdown files. Use nc_webdav_write_file Pages are created as empty markdown files. Use nc_webdav_write_file
with the page's collectivePath/filePath to add content after creation. with the path collectivePath/filePath/fileName to add content after
creation (omit filePath for root-level pages).
Args: Args:
collective_id: ID of the collective collective_id: ID of the collective
@@ -279,15 +283,16 @@ def configure_collectives_tools(mcp: FastMCP):
copy: If true, copy instead of move copy: If true, copy instead of move
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.move_page( raw = await client.collectives.move_page(
collective_id, page_id, parent_id, title, index, copy collective_id, page_id, parent_id, title, index, copy
) )
page = PageInfo(**raw)
action = "copied" if copy else "moved" action = "copied" if copy else "moved"
return PageOperationResponse( return PageOperationResponse(
page_id=page_id, page_id=page.id,
collective_id=collective_id, collective_id=collective_id,
status_code=200, status_code=200,
message=f"Page {action}", message=f"Page {action} (title: {page.title}, parent: {page.parentId})",
) )
@mcp.tool( @mcp.tool(
@@ -360,12 +365,13 @@ def configure_collectives_tools(mcp: FastMCP):
emoji: Emoji to set, or null to clear emoji: Emoji to set, or null to clear
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.set_page_emoji(collective_id, page_id, emoji) raw = await client.collectives.set_page_emoji(collective_id, page_id, emoji)
page = PageInfo(**raw)
return PageOperationResponse( return PageOperationResponse(
page_id=page_id, page_id=page.id,
collective_id=collective_id, collective_id=collective_id,
status_code=200, status_code=200,
message="Page emoji updated", message=f"Page emoji updated (emoji: {page.emoji})",
) )
@mcp.tool( @mcp.tool(
@@ -3,7 +3,7 @@
import httpx import httpx
import pytest import pytest
from nextcloud_mcp_server.client.collectives import CollectivesClient from nextcloud_mcp_server.client.collectives import CollectivesClient, OCSError
from tests.client.conftest import create_mock_response from tests.client.conftest import create_mock_response
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -46,7 +46,7 @@ def _sample_page(
"title": title, "title": title,
"emoji": None, "emoji": None,
"fileName": f"{title}.md", "fileName": f"{title}.md",
"filePath": f"{title}.md", "filePath": "",
"collectivePath": collective_path, "collectivePath": collective_path,
"parentId": parent_id, "parentId": parent_id,
"timestamp": 1700000000, "timestamp": 1700000000,
@@ -327,6 +327,28 @@ async def test_restore_page(mocker):
# --- Error Handling --- # --- Error Handling ---
async def test_ocs_error_status_raises(mocker):
"""Test that OCS envelope with error statuscode raises OCSError."""
mock_response = create_mock_response(
status_code=200,
json_data={
"ocs": {
"meta": {
"status": "failure",
"statuscode": 403,
"message": "Not permitted",
},
"data": {},
}
},
)
mocker.patch.object(CollectivesClient, "_make_request", return_value=mock_response)
client = CollectivesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
with pytest.raises(OCSError, match="Not permitted"):
await client.get_collectives()
async def test_get_collectives_403(mocker): async def test_get_collectives_403(mocker):
"""Test 403 response raises HTTPStatusError.""" """Test 403 response raises HTTPStatusError."""
mock_response = create_mock_response( mock_response = create_mock_response(