"""MCP tool definitions for Nextcloud Collectives app.""" import logging from httpx import HTTPStatusError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData, ToolAnnotations 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.models.collectives import ( Collective, CollectiveOperationResponse, CollectiveTag, CreateCollectiveResponse, CreatePageResponse, CreateTagResponse, GetPageResponse, ListCollectivesResponse, ListPagesResponse, ListTagsResponse, ListTrashedCollectivesResponse, ListTrashedPagesResponse, PageInfo, PageOperationResponse, SearchPagesResponse, ) from nextcloud_mcp_server.observability.metrics import instrument_tool 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=-32603, message=e.message)) return McpError(ErrorData(code=-32603, message=str(e))) def configure_collectives_tools(mcp: FastMCP): """Configure Nextcloud Collectives tools for the MCP server.""" # --- Read Tools --- @mcp.tool( title="List Collectives", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_collectives( ctx: Context, ) -> ListCollectivesResponse: """List all Nextcloud Collectives the user has access to""" client = await get_client(ctx) 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] return ListCollectivesResponse(collectives=collectives, total=len(collectives)) @mcp.tool( title="List Collective Pages", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_pages( ctx: Context, collective_id: int ) -> ListPagesResponse: """List all pages in a Nextcloud Collective Args: collective_id: ID of the collective """ client = await get_client(ctx) 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] return ListPagesResponse( pages=pages, total=len(pages), collective_id=collective_id ) @mcp.tool( title="Get Collective Page", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_page( ctx: Context, collective_id: int, page_id: int ) -> GetPageResponse: """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 page content, use the nc_webdav_write_file tool with the path collectivePath/filePath/fileName (omit filePath for root-level pages). Args: collective_id: ID of the collective page_id: ID of the page """ client = await get_client(ctx) 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) # Fetch content via WebDAV # Path structure: collectivePath/filePath/fileName # filePath is empty for root-level pages, contains subdirectory for nested pages content = None if page.collectivePath and page.fileName: parts = [page.collectivePath] if page.filePath: parts.append(page.filePath) parts.append(page.fileName) webdav_path = "/".join(p.strip("/") for p in parts) try: file_bytes, _ = await client.webdav.read_file(webdav_path) content = file_bytes.decode("utf-8") except (HTTPStatusError, OSError, UnicodeDecodeError) as e: logger.warning( "Failed to read page content via WebDAV: %s: %s", webdav_path, e, ) return GetPageResponse(page=page, content=content) @mcp.tool( title="Search Collective Pages", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_search_pages( ctx: Context, collective_id: int, query: str ) -> SearchPagesResponse: """Full-text search within a Nextcloud Collective Args: collective_id: ID of the collective query: Search query string """ client = await get_client(ctx) 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] return SearchPagesResponse( results=pages, total=len(pages), query=query, collective_id=collective_id, ) @mcp.tool( title="List Collective Tags", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_tags( ctx: Context, collective_id: int ) -> ListTagsResponse: """List all tags in a Nextcloud Collective Args: collective_id: ID of the collective """ client = await get_client(ctx) 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] return ListTagsResponse(tags=tags, total=len(tags), collective_id=collective_id) @mcp.tool( title="List Trashed Collective Pages", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_trashed_pages( ctx: Context, collective_id: int ) -> ListTrashedPagesResponse: """List trashed pages in a Nextcloud Collective Args: collective_id: ID of the collective """ client = await get_client(ctx) 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] return ListTrashedPagesResponse( pages=pages, total=len(pages), collective_id=collective_id ) @mcp.tool( title="List Trashed Collectives", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), ) @require_scopes("collectives.read") @instrument_tool async def collectives_get_trashed_collectives( ctx: Context, ) -> ListTrashedCollectivesResponse: """List all trashed Nextcloud Collectives. Returns collectives that have been soft-deleted and can be restored or permanently deleted. """ client = await get_client(ctx) try: raw = await client.collectives.get_trashed_collectives() except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e collectives = [Collective(**c) for c in raw] return ListTrashedCollectivesResponse( collectives=collectives, total=len(collectives) ) # --- Write Tools --- @mcp.tool( title="Create Collective", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_create_collective( ctx: Context, name: str, emoji: str | None = None ) -> CreateCollectiveResponse: """Create a new Nextcloud Collective Args: name: Name of the collective emoji: Optional emoji for the collective """ client = await get_client(ctx) try: raw = await client.collectives.create_collective(name, emoji) except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e collective = Collective(**raw) return CreateCollectiveResponse( id=collective.id, name=collective.name, emoji=collective.emoji ) @mcp.tool( title="Set Collective Emoji", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_set_collective_emoji( ctx: Context, collective_id: int, emoji: str | None = None ) -> CollectiveOperationResponse: """Set or clear the emoji on a Nextcloud Collective. Setting the same emoji twice produces the same result (idempotent). Pass emoji=None to clear the emoji. Args: collective_id: ID of the collective emoji: Emoji to set, or None to clear """ client = await get_client(ctx) try: raw = await client.collectives.update_collective(collective_id, emoji) except ValueError as e: raise McpError(ErrorData(code=-32603, message=str(e))) from e except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e collective = Collective(**raw) return CollectiveOperationResponse( collective_id=collective.id, status_code=200, message=f"Collective emoji set to: {collective.emoji}", ) @mcp.tool( title="Trash Collective", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_trash_collective( ctx: Context, collective_id: int ) -> CollectiveOperationResponse: """Move a Nextcloud Collective to trash (soft delete). The collective can be restored or permanently deleted afterwards. Args: collective_id: ID of the collective to trash """ client = await get_client(ctx) try: await client.collectives.trash_collective(collective_id) except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e return CollectiveOperationResponse( collective_id=collective_id, status_code=200, message="Collective moved to trash", ) @mcp.tool( title="Delete Collective", annotations=ToolAnnotations( destructiveHint=True, idempotentHint=False, openWorldHint=True ), ) @require_scopes("collectives.write") @instrument_tool async def collectives_delete_collective( ctx: Context, collective_id: int ) -> CollectiveOperationResponse: """Permanently delete a Nextcloud Collective. WARNING: This is irreversible. The collective must be in the trash first (use collectives_trash_collective). All pages and content will be permanently destroyed. Args: collective_id: ID of the trashed collective to permanently delete """ client = await get_client(ctx) try: await client.collectives.delete_collective(collective_id) except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e return CollectiveOperationResponse( collective_id=collective_id, status_code=200, message="Collective permanently deleted", ) @mcp.tool( title="Restore Collective", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_restore_collective( ctx: Context, collective_id: int ) -> CollectiveOperationResponse: """Restore a Nextcloud Collective from trash. Args: collective_id: ID of the trashed collective to restore """ client = await get_client(ctx) try: raw = await client.collectives.restore_collective(collective_id) except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e collective = Collective(**raw) return CollectiveOperationResponse( collective_id=collective.id, status_code=200, message=f"Collective '{collective.name}' restored from trash", ) @mcp.tool( title="Create Collective Page", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_create_page( ctx: Context, collective_id: int, parent_id: int, title: str ) -> CreatePageResponse: """Create a new page in a Nextcloud Collective. Pages are created as empty markdown files. Use nc_webdav_write_file with the path collectivePath/filePath/fileName to add content after creation (omit filePath for root-level pages). Args: collective_id: ID of the collective parent_id: ID of the parent page (use 0 for top-level pages) title: Title of the new page """ client = await get_client(ctx) 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) return CreatePageResponse( id=page.id, title=page.title, collective_id=collective_id, parent_id=page.parentId, ) @mcp.tool( title="Move Collective Page", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_move_page( ctx: Context, collective_id: int, page_id: int, parent_id: int | None = None, title: str | None = None, index: int = 0, copy: bool = False, ) -> PageOperationResponse: """Move or copy a page within a Nextcloud Collective Args: collective_id: ID of the collective page_id: ID of the page to move/copy parent_id: Target parent page ID title: New title (optional) index: Position in subpage order (default 0) copy: If true, copy instead of move """ client = await get_client(ctx) try: 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) action = "copied" if copy else "moved" return PageOperationResponse( page_id=page.id, collective_id=collective_id, status_code=200, message=f"Page {action} (title: {page.title}, parent: {page.parentId})", ) @mcp.tool( title="Trash Collective Page", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_trash_page( ctx: Context, collective_id: int, page_id: int ) -> PageOperationResponse: """Move a page to trash in a Nextcloud Collective (soft delete). Trashed pages can be restored with collectives_restore_page. The Collectives API does not support permanent page deletion; trashed pages are cleaned up by Nextcloud's retention policy. Args: collective_id: ID of the collective page_id: ID of the page to trash """ client = await get_client(ctx) try: await client.collectives.trash_page(collective_id, page_id) except (OCSError, HTTPStatusError) as e: raise _handle_collectives_error(e) from e return PageOperationResponse( page_id=page_id, collective_id=collective_id, status_code=200, message="Page moved to trash", ) @mcp.tool( title="Restore Collective Page", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_restore_page( ctx: Context, collective_id: int, page_id: int ) -> PageOperationResponse: """Restore a page from trash in a Nextcloud Collective Args: collective_id: ID of the collective page_id: ID of the page to restore """ client = await get_client(ctx) 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( page_id=page.id, collective_id=collective_id, status_code=200, message=f"Page restored from trash (title: {page.title})", ) @mcp.tool( title="Set Collective Page Emoji", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_set_page_emoji( ctx: Context, collective_id: int, page_id: int, emoji: str | None = None, ) -> PageOperationResponse: """Set or clear the emoji on a Nextcloud Collective page Args: collective_id: ID of the collective page_id: ID of the page emoji: Emoji to set, or null to clear """ client = await get_client(ctx) 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) return PageOperationResponse( page_id=page.id, collective_id=collective_id, status_code=200, message=f"Page emoji updated (emoji: {page.emoji})", ) @mcp.tool( title="Create Collective Tag", annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_create_tag( ctx: Context, collective_id: int, name: str, color: str ) -> CreateTagResponse: """Create a new tag in a Nextcloud Collective Args: collective_id: ID of the collective name: Tag name color: Hex color code (e.g. "FF0000") """ client = await get_client(ctx) 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) return CreateTagResponse(id=tag.id, name=tag.name, color=tag.color) @mcp.tool( title="Assign Tag to Collective Page", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_assign_tag( ctx: Context, collective_id: int, page_id: int, tag_id: int ) -> PageOperationResponse: """Assign a tag to a page in a Nextcloud Collective Args: collective_id: ID of the collective page_id: ID of the page tag_id: ID of the tag to assign """ client = await get_client(ctx) 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( page_id=page_id, collective_id=collective_id, status_code=200, message=f"Tag {tag_id} assigned to page", ) @mcp.tool( title="Remove Tag from Collective Page", annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True), ) @require_scopes("collectives.write") @instrument_tool async def collectives_remove_tag( ctx: Context, collective_id: int, page_id: int, tag_id: int ) -> PageOperationResponse: """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: collective_id: ID of the collective page_id: ID of the page tag_id: ID of the tag to remove """ client = await get_client(ctx) 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( page_id=page_id, collective_id=collective_id, status_code=200, message=f"Tag {tag_id} removed from page", )