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:
co-authored by
Claude Opus 4.6
parent
32f5a0fe52
commit
e5ad625a66
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user