fix: address PR review feedback (round 2)

Bug fixes:
- Catch OCSError/HTTPStatusError in all server tools, convert to McpError
- Guard update_collective against empty body (raise ValueError)
- Use restore_page response data in status message

ADR-017 annotation fix:
- Distinguish "remove" (reversible association) from "delete" (permanent):
  remove_tag and deck_remove_label_from_card no longer set destructiveHint
- Update annotation test to exclude "remove" from destructive keywords

Data model improvements:
- Add trashTimestamp field to PageInfo
- Create ListTrashedPagesResponse with is_trash context flag
- Add collective_id to ListTagsResponse

Test robustness:
- Read NC credentials from environment variables (not hardcoded)
- Filter landing page by parentId == 0 instead of assuming pages[0]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-25 09:08:13 +01:00
co-authored by Claude Opus 4.6
parent 3393cd9756
commit 44a27bd9e9
6 changed files with 125 additions and 40 deletions
+7 -1
View File
@@ -71,10 +71,16 @@ class CollectivesClient(BaseNextcloudClient):
async def update_collective( async def update_collective(
self, collective_id: int, emoji: str | None = None self, collective_id: int, emoji: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Update a collective (emoji).""" """Update a collective (emoji).
Raises:
ValueError: If no fields are provided to update.
"""
json_data: dict[str, Any] = {} json_data: dict[str, Any] = {}
if emoji is not None: if emoji is not None:
json_data["emoji"] = emoji json_data["emoji"] = emoji
if not json_data:
raise ValueError("At least one field must be provided to update")
response = await self._make_request( response = await self._make_request(
"PUT", "PUT",
f"{API_BASE}/collectives/{collective_id}", f"{API_BASE}/collectives/{collective_id}",
@@ -42,6 +42,9 @@ class PageInfo(BaseModel):
default_factory=list, description="Ordered subpage IDs" default_factory=list, description="Ordered subpage IDs"
) )
isFullWidth: bool | None = Field(default=None, description="Full-width page layout") isFullWidth: bool | None = Field(default=None, description="Full-width page layout")
trashTimestamp: int | None = Field(
default=None, description="Timestamp when the page was trashed"
)
class CollectiveTag(BaseModel): class CollectiveTag(BaseModel):
@@ -120,11 +123,20 @@ class SearchPagesResponse(BaseResponse):
collective_id: int = Field(description="Collective ID") collective_id: int = Field(description="Collective ID")
class ListTrashedPagesResponse(ListPagesResponse):
"""Response for listing trashed pages in a collective."""
is_trash: bool = Field(
default=True, description="Indicates these are trashed pages"
)
class ListTagsResponse(BaseResponse): class ListTagsResponse(BaseResponse):
"""Response for listing tags in a collective.""" """Response for listing tags in a collective."""
tags: list[CollectiveTag] = Field(description="List of tags") tags: list[CollectiveTag] = Field(description="List of tags")
total: int = Field(description="Total number of tags") total: int = Field(description="Total number of tags")
collective_id: int = Field(description="Collective ID")
class CreateTagResponse(BaseResponse): class CreateTagResponse(BaseResponse):
+90 -28
View File
@@ -4,9 +4,11 @@ import logging
from httpx import HTTPStatusError 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.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.client.collectives import OCSError
from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.collectives import ( from nextcloud_mcp_server.models.collectives import (
Collective, Collective,
@@ -19,6 +21,7 @@ from nextcloud_mcp_server.models.collectives import (
ListCollectivesResponse, ListCollectivesResponse,
ListPagesResponse, ListPagesResponse,
ListTagsResponse, ListTagsResponse,
ListTrashedPagesResponse,
PageInfo, PageInfo,
PageOperationResponse, PageOperationResponse,
SearchPagesResponse, SearchPagesResponse,
@@ -28,6 +31,13 @@ from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _handle_collectives_error(e: OCSError | HTTPStatusError) -> McpError:
"""Convert OCS or HTTP errors to McpError."""
if isinstance(e, OCSError):
return McpError(ErrorData(code=e.status_code, message=e.message))
return McpError(ErrorData(code=e.response.status_code, message=str(e)))
def configure_collectives_tools(mcp: FastMCP): def configure_collectives_tools(mcp: FastMCP):
"""Configure Nextcloud Collectives tools for the MCP server.""" """Configure Nextcloud Collectives tools for the MCP server."""
@@ -44,7 +54,10 @@ def configure_collectives_tools(mcp: FastMCP):
) -> ListCollectivesResponse: ) -> ListCollectivesResponse:
"""List all Nextcloud Collectives the user has access to""" """List all Nextcloud Collectives the user has access to"""
client = await get_client(ctx) client = await get_client(ctx)
raw_collectives = await client.collectives.get_collectives() try:
raw_collectives = await client.collectives.get_collectives()
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collectives = [Collective(**c) for c in raw_collectives] collectives = [Collective(**c) for c in raw_collectives]
return ListCollectivesResponse(collectives=collectives, total=len(collectives)) return ListCollectivesResponse(collectives=collectives, total=len(collectives))
@@ -63,7 +76,10 @@ def configure_collectives_tools(mcp: FastMCP):
collective_id: ID of the collective collective_id: ID of the collective
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw_pages = await client.collectives.get_pages(collective_id) try:
raw_pages = await client.collectives.get_pages(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages] pages = [PageInfo(**p) for p in raw_pages]
return ListPagesResponse( return ListPagesResponse(
pages=pages, total=len(pages), collective_id=collective_id pages=pages, total=len(pages), collective_id=collective_id
@@ -89,7 +105,10 @@ def configure_collectives_tools(mcp: FastMCP):
page_id: ID of the page page_id: ID of the page
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw_page = await client.collectives.get_page(collective_id, page_id) try:
raw_page = await client.collectives.get_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw_page) page = PageInfo(**raw_page)
# Fetch content via WebDAV # Fetch content via WebDAV
@@ -130,7 +149,10 @@ def configure_collectives_tools(mcp: FastMCP):
query: Search query string query: Search query string
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw_pages = await client.collectives.search_pages(collective_id, query) try:
raw_pages = await client.collectives.search_pages(collective_id, query)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages] pages = [PageInfo(**p) for p in raw_pages]
return SearchPagesResponse( return SearchPagesResponse(
results=pages, results=pages,
@@ -154,9 +176,12 @@ def configure_collectives_tools(mcp: FastMCP):
collective_id: ID of the collective collective_id: ID of the collective
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw_tags = await client.collectives.get_tags(collective_id) try:
raw_tags = await client.collectives.get_tags(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
tags = [CollectiveTag(**t) for t in raw_tags] tags = [CollectiveTag(**t) for t in raw_tags]
return ListTagsResponse(tags=tags, total=len(tags)) return ListTagsResponse(tags=tags, total=len(tags), collective_id=collective_id)
@mcp.tool( @mcp.tool(
title="List Trashed Collective Pages", title="List Trashed Collective Pages",
@@ -166,16 +191,19 @@ def configure_collectives_tools(mcp: FastMCP):
@instrument_tool @instrument_tool
async def collectives_get_trashed_pages( async def collectives_get_trashed_pages(
ctx: Context, collective_id: int ctx: Context, collective_id: int
) -> ListPagesResponse: ) -> ListTrashedPagesResponse:
"""List trashed pages in a Nextcloud Collective """List trashed pages in a Nextcloud Collective
Args: Args:
collective_id: ID of the collective collective_id: ID of the collective
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw_pages = await client.collectives.get_trashed_pages(collective_id) try:
raw_pages = await client.collectives.get_trashed_pages(collective_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
pages = [PageInfo(**p) for p in raw_pages] pages = [PageInfo(**p) for p in raw_pages]
return ListPagesResponse( return ListTrashedPagesResponse(
pages=pages, total=len(pages), collective_id=collective_id pages=pages, total=len(pages), collective_id=collective_id
) )
@@ -197,7 +225,10 @@ def configure_collectives_tools(mcp: FastMCP):
emoji: Optional emoji for the collective emoji: Optional emoji for the collective
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw = await client.collectives.create_collective(name, emoji) try:
raw = await client.collectives.create_collective(name, emoji)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collective = Collective(**raw) collective = Collective(**raw)
return CreateCollectiveResponse( return CreateCollectiveResponse(
id=collective.id, name=collective.name, emoji=collective.emoji id=collective.id, name=collective.name, emoji=collective.emoji
@@ -219,7 +250,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)
raw = await client.collectives.update_collective(collective_id, emoji) try:
raw = await client.collectives.update_collective(collective_id, emoji)
except ValueError as e:
raise McpError(ErrorData(code=400, message=str(e))) from e
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
collective = Collective(**raw) collective = Collective(**raw)
return CollectiveOperationResponse( return CollectiveOperationResponse(
collective_id=collective.id, collective_id=collective.id,
@@ -248,7 +284,10 @@ def configure_collectives_tools(mcp: FastMCP):
title: Title of the new page title: Title of the new page
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw = await client.collectives.create_page(collective_id, parent_id, title) try:
raw = await client.collectives.create_page(collective_id, parent_id, title)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw) page = PageInfo(**raw)
return CreatePageResponse( return CreatePageResponse(
id=page.id, id=page.id,
@@ -283,9 +322,12 @@ 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)
raw = await client.collectives.move_page( try:
collective_id, page_id, parent_id, title, index, copy raw = await client.collectives.move_page(
) collective_id, page_id, parent_id, title, index, copy
)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw) page = PageInfo(**raw)
action = "copied" if copy else "moved" action = "copied" if copy else "moved"
return PageOperationResponse( return PageOperationResponse(
@@ -313,7 +355,10 @@ def configure_collectives_tools(mcp: FastMCP):
page_id: ID of the page to trash page_id: ID of the page to trash
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.trash_page(collective_id, page_id) try:
await client.collectives.trash_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse( return PageOperationResponse(
page_id=page_id, page_id=page_id,
collective_id=collective_id, collective_id=collective_id,
@@ -337,12 +382,16 @@ def configure_collectives_tools(mcp: FastMCP):
page_id: ID of the page to restore page_id: ID of the page to restore
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.restore_page(collective_id, page_id) try:
raw = await client.collectives.restore_page(collective_id, page_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
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 restored from trash", message=f"Page restored from trash (title: {page.title})",
) )
@mcp.tool( @mcp.tool(
@@ -365,7 +414,10 @@ 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)
raw = await client.collectives.set_page_emoji(collective_id, page_id, emoji) try:
raw = await client.collectives.set_page_emoji(collective_id, page_id, emoji)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
page = PageInfo(**raw) page = PageInfo(**raw)
return PageOperationResponse( return PageOperationResponse(
page_id=page.id, page_id=page.id,
@@ -391,7 +443,10 @@ def configure_collectives_tools(mcp: FastMCP):
color: Hex color code (e.g. "FF0000") color: Hex color code (e.g. "FF0000")
""" """
client = await get_client(ctx) client = await get_client(ctx)
raw = await client.collectives.create_tag(collective_id, name, color) try:
raw = await client.collectives.create_tag(collective_id, name, color)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
tag = CollectiveTag(**raw) tag = CollectiveTag(**raw)
return CreateTagResponse(id=tag.id, name=tag.name, color=tag.color) return CreateTagResponse(id=tag.id, name=tag.name, color=tag.color)
@@ -412,7 +467,10 @@ def configure_collectives_tools(mcp: FastMCP):
tag_id: ID of the tag to assign tag_id: ID of the tag to assign
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.assign_tag(collective_id, page_id, tag_id) try:
await client.collectives.assign_tag(collective_id, page_id, tag_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse( return PageOperationResponse(
page_id=page_id, page_id=page_id,
collective_id=collective_id, collective_id=collective_id,
@@ -422,16 +480,17 @@ def configure_collectives_tools(mcp: FastMCP):
@mcp.tool( @mcp.tool(
title="Remove Tag from Collective Page", title="Remove Tag from Collective Page",
annotations=ToolAnnotations( annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
) )
@require_scopes("collectives:write") @require_scopes("collectives:write")
@instrument_tool @instrument_tool
async def collectives_remove_tag( async def collectives_remove_tag(
ctx: Context, collective_id: int, page_id: int, tag_id: int ctx: Context, collective_id: int, page_id: int, tag_id: int
) -> PageOperationResponse: ) -> PageOperationResponse:
"""Remove a tag from a page in a Nextcloud Collective """Remove a tag from a page in a Nextcloud Collective.
This is a reversible operation — the tag still exists and can be
reassigned with collectives_assign_tag.
Args: Args:
collective_id: ID of the collective collective_id: ID of the collective
@@ -439,7 +498,10 @@ def configure_collectives_tools(mcp: FastMCP):
tag_id: ID of the tag to remove tag_id: ID of the tag to remove
""" """
client = await get_client(ctx) client = await get_client(ctx)
await client.collectives.remove_tag(collective_id, page_id, tag_id) try:
await client.collectives.remove_tag(collective_id, page_id, tag_id)
except (OCSError, HTTPStatusError) as e:
raise _handle_collectives_error(e) from e
return PageOperationResponse( return PageOperationResponse(
page_id=page_id, page_id=page_id,
collective_id=collective_id, collective_id=collective_id,
+1 -3
View File
@@ -641,9 +641,7 @@ def configure_deck_tools(mcp: FastMCP):
@mcp.tool( @mcp.tool(
title="Remove Label from Deck Card", title="Remove Label from Deck Card",
annotations=ToolAnnotations( annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
) )
@require_scopes("deck:write") @require_scopes("deck:write")
@instrument_tool @instrument_tool
+3 -2
View File
@@ -58,8 +58,9 @@ async def test_destructive_tools_have_correct_annotations(nc_mcp_client: ClientS
"""Verify destructive operations are marked correctly.""" """Verify destructive operations are marked correctly."""
tools = await nc_mcp_client.list_tools() tools = await nc_mcp_client.list_tools()
# Known destructive operations # Known destructive operations (permanently delete data).
destructive_keywords = ["delete", "remove", "revoke"] # "remove" is excluded — removing associations (labels, tags) is reversible.
destructive_keywords = ["delete", "revoke"]
for tool in tools.tools: for tool in tools.tools:
has_destructive_keyword = any( has_destructive_keyword = any(
+12 -6
View File
@@ -2,6 +2,7 @@
import json import json
import logging import logging
import os
import uuid import uuid
import httpx import httpx
@@ -11,9 +12,10 @@ from mcp import ClientSession
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
# Nextcloud credentials for direct API cleanup (matches docker-compose.yml) # Nextcloud credentials from environment (matches .envrc / docker-compose.yml defaults)
_NC_BASE = "http://localhost:8080" _NC_BASE = os.environ.get("NEXTCLOUD_HOST", "http://localhost:8080")
_NC_AUTH = ("admin", "admin") _NC_USER = os.environ.get("NEXTCLOUD_USERNAME", "admin")
_NC_PASS = os.environ.get("NEXTCLOUD_PASSWORD", "admin")
_OCS_HEADERS = { _OCS_HEADERS = {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
"Accept": "application/json", "Accept": "application/json",
@@ -38,13 +40,15 @@ async def temporary_collective(nc_mcp_client: ClientSession):
collective_id = data["id"] collective_id = data["id"]
logger.info(f"Created temporary collective: {name} (ID: {collective_id})") logger.info(f"Created temporary collective: {name} (ID: {collective_id})")
# Get the landing page ID (auto-created with each collective) # Get the landing page ID — filter by parentId == 0 (root page)
pages_result = await nc_mcp_client.call_tool( pages_result = await nc_mcp_client.call_tool(
"collectives_get_pages", "collectives_get_pages",
{"collective_id": collective_id}, {"collective_id": collective_id},
) )
pages_data = json.loads(pages_result.content[0].text) pages_data = json.loads(pages_result.content[0].text)
landing_page_id = pages_data["pages"][0]["id"] root_pages = [p for p in pages_data["pages"] if p["parentId"] == 0]
assert root_pages, "Expected at least one root page (landing page)"
landing_page_id = root_pages[0]["id"]
yield { yield {
"id": collective_id, "id": collective_id,
@@ -54,7 +58,9 @@ async def temporary_collective(nc_mcp_client: ClientSession):
# Cleanup: trash and permanently delete the collective via direct OCS API # Cleanup: trash and permanently delete the collective via direct OCS API
try: try:
async with httpx.AsyncClient(base_url=_NC_BASE, auth=_NC_AUTH) as client: async with httpx.AsyncClient(
base_url=_NC_BASE, auth=(_NC_USER, _NC_PASS)
) as client:
api = "/ocs/v2.php/apps/collectives/api/v1.0" api = "/ocs/v2.php/apps/collectives/api/v1.0"
await client.delete( await client.delete(
f"{api}/collectives/{collective_id}", f"{api}/collectives/{collective_id}",