feat: add Nextcloud Collectives app support (#621)
Implement MCP tools for the Collectives wiki/documentation app, enabling agentic workflows for team knowledge base management. 16 tools covering collectives, pages, tags, search, and trash: - Read: list collectives, list/get pages (with WebDAV content), search, list tags, list trashed pages - Write: create/update collective, create/move/trash/restore pages, set emoji, create/assign/remove tags Includes Docker hook for app installation, OCS API client with envelope unwrapping, Pydantic models, unit tests (16), and integration tests (10). Closes #621 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
7cbe323ef9
commit
32f5a0fe52
@@ -117,6 +117,7 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
)
|
||||
from nextcloud_mcp_server.server import (
|
||||
configure_calendar_tools,
|
||||
configure_collectives_tools,
|
||||
configure_contacts_tools,
|
||||
configure_cookbook_tools,
|
||||
configure_deck_tools,
|
||||
@@ -1274,6 +1275,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
"webdav": configure_webdav_tools,
|
||||
"sharing": configure_sharing_tools,
|
||||
"calendar": configure_calendar_tools,
|
||||
"collectives": configure_collectives_tools,
|
||||
"contacts": configure_contacts_tools,
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
|
||||
@@ -14,6 +14,7 @@ from httpx import (
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from ..http import nextcloud_httpx_transport
|
||||
from .calendar import CalendarClient
|
||||
from .collectives import CollectivesClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
||||
) # Uses AsyncDavClient internally
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.collectives = CollectivesClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.news = NewsClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Client for Nextcloud Collectives app API (OCS)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
|
||||
API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0"
|
||||
|
||||
|
||||
class CollectivesClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Collectives app operations."""
|
||||
|
||||
app_name = "collectives"
|
||||
|
||||
def _get_ocs_headers(
|
||||
self, additional_headers: dict[str, str] | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Get standard headers required for OCS API calls."""
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any:
|
||||
"""Unwrap OCS envelope, returning the data payload."""
|
||||
return response_json["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._get_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._get_ocs_headers(),
|
||||
)
|
||||
data = self._unwrap_ocs(response.json())
|
||||
return data["collective"]
|
||||
|
||||
async def update_collective(
|
||||
self, collective_id: int, emoji: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Update a collective (emoji)."""
|
||||
json_data: dict[str, Any] = {}
|
||||
if emoji is not None:
|
||||
json_data["emoji"] = emoji
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"{API_BASE}/collectives/{collective_id}",
|
||||
json=json_data,
|
||||
headers=self._get_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._get_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._get_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._get_ocs_headers(),
|
||||
)
|
||||
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._get_ocs_headers(),
|
||||
)
|
||||
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)."""
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
|
||||
headers=self._get_ocs_headers(),
|
||||
)
|
||||
|
||||
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."""
|
||||
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._get_ocs_headers(),
|
||||
)
|
||||
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._get_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._get_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._get_ocs_headers(),
|
||||
)
|
||||
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."""
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
|
||||
headers=self._get_ocs_headers(),
|
||||
)
|
||||
|
||||
async def remove_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
|
||||
"""Remove a tag from a page."""
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
|
||||
headers=self._get_ocs_headers(),
|
||||
)
|
||||
|
||||
# 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._get_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._get_ocs_headers(),
|
||||
)
|
||||
data = self._unwrap_ocs(response.json())
|
||||
return data["page"]
|
||||
@@ -74,5 +74,7 @@ ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||
"sharing:write",
|
||||
"news:read",
|
||||
"news:write",
|
||||
"collectives:read",
|
||||
"collectives:write",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Pydantic models for Nextcloud Collectives app."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
# Domain Models
|
||||
|
||||
|
||||
class Collective(BaseModel):
|
||||
"""A Nextcloud Collective (wiki/knowledge base)."""
|
||||
|
||||
id: int = Field(description="Collective ID")
|
||||
circleId: str = Field(description="Linked Circle/Team ID")
|
||||
emoji: str | None = Field(default=None, description="Collective emoji")
|
||||
name: str = Field(description="Collective name")
|
||||
level: int = Field(description="User's membership level")
|
||||
canEdit: bool = Field(description="Whether the user can edit")
|
||||
canShare: bool = Field(description="Whether the user can share")
|
||||
pageMode: int = Field(description="Default page mode: 0=view, 1=edit")
|
||||
|
||||
|
||||
class PageInfo(BaseModel):
|
||||
"""A page within a Collective."""
|
||||
|
||||
id: int = Field(description="Page ID")
|
||||
title: str = Field(description="Page title")
|
||||
emoji: str | None = Field(default=None, description="Page emoji")
|
||||
fileName: str = Field(description="Markdown file name")
|
||||
filePath: str = Field(description="File path within the collective")
|
||||
collectivePath: str | None = Field(
|
||||
default=None, description="Collective folder path in user's files"
|
||||
)
|
||||
parentId: int = Field(description="Parent page ID (0 for root)")
|
||||
timestamp: int = Field(description="Last modification Unix timestamp")
|
||||
size: int = Field(description="Content size in bytes")
|
||||
lastUserId: str | None = Field(default=None, description="Last editor user ID")
|
||||
lastUserDisplayName: str | None = Field(
|
||||
default=None, description="Last editor display name"
|
||||
)
|
||||
subpageOrder: list[int] = Field(
|
||||
default_factory=list, description="Ordered subpage IDs"
|
||||
)
|
||||
isFullWidth: bool | None = Field(default=None, description="Full-width page layout")
|
||||
|
||||
|
||||
class CollectiveTag(BaseModel):
|
||||
"""A tag within a Collective."""
|
||||
|
||||
id: int = Field(description="Tag ID")
|
||||
collectiveId: int = Field(description="Parent collective ID")
|
||||
name: str = Field(description="Tag name")
|
||||
color: str = Field(description="Hex color code")
|
||||
|
||||
|
||||
# Response Models
|
||||
|
||||
|
||||
class ListCollectivesResponse(BaseResponse):
|
||||
"""Response for listing collectives."""
|
||||
|
||||
collectives: list[Collective] = Field(description="List of collectives")
|
||||
total: int = Field(description="Total number of collectives")
|
||||
|
||||
|
||||
class CreateCollectiveResponse(BaseResponse):
|
||||
"""Response for creating a collective."""
|
||||
|
||||
id: int = Field(description="Created collective ID")
|
||||
name: str = Field(description="Created collective name")
|
||||
emoji: str | None = Field(default=None, description="Collective emoji")
|
||||
|
||||
|
||||
class CollectiveOperationResponse(StatusResponse):
|
||||
"""Response for collective update operations."""
|
||||
|
||||
collective_id: int = Field(description="ID of the affected collective")
|
||||
|
||||
|
||||
class ListPagesResponse(BaseResponse):
|
||||
"""Response for listing pages in a collective."""
|
||||
|
||||
pages: list[PageInfo] = Field(description="List of pages")
|
||||
total: int = Field(description="Total number of pages")
|
||||
collective_id: int = Field(description="Collective ID")
|
||||
|
||||
|
||||
class GetPageResponse(BaseResponse):
|
||||
"""Response for getting a single page with content."""
|
||||
|
||||
page: PageInfo = Field(description="Page metadata")
|
||||
content: str | None = Field(
|
||||
default=None,
|
||||
description="Page markdown content (fetched via WebDAV)",
|
||||
)
|
||||
|
||||
|
||||
class CreatePageResponse(BaseResponse):
|
||||
"""Response for creating a page."""
|
||||
|
||||
id: int = Field(description="Created page ID")
|
||||
title: str = Field(description="Created page title")
|
||||
collective_id: int = Field(description="Collective ID")
|
||||
parent_id: int = Field(description="Parent page ID")
|
||||
|
||||
|
||||
class PageOperationResponse(StatusResponse):
|
||||
"""Response for page operations (update, trash, emoji, tag)."""
|
||||
|
||||
page_id: int = Field(description="ID of the affected page")
|
||||
collective_id: int = Field(description="Collective ID")
|
||||
|
||||
|
||||
class SearchPagesResponse(BaseResponse):
|
||||
"""Response for full-text search within a collective."""
|
||||
|
||||
results: list[PageInfo] = Field(description="Matching pages")
|
||||
total: int = Field(description="Total number of results")
|
||||
query: str = Field(description="Search query")
|
||||
collective_id: int = Field(description="Collective ID")
|
||||
|
||||
|
||||
class ListTagsResponse(BaseResponse):
|
||||
"""Response for listing tags in a collective."""
|
||||
|
||||
tags: list[CollectiveTag] = Field(description="List of tags")
|
||||
total: int = Field(description="Total number of tags")
|
||||
|
||||
|
||||
class CreateTagResponse(BaseResponse):
|
||||
"""Response for creating a tag."""
|
||||
|
||||
id: int = Field(description="Created tag ID")
|
||||
name: str = Field(description="Tag name")
|
||||
color: str = Field(description="Tag color")
|
||||
@@ -1,4 +1,5 @@
|
||||
from .calendar import configure_calendar_tools
|
||||
from .collectives import configure_collectives_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
@@ -11,6 +12,7 @@ from .webdav import configure_webdav_tools
|
||||
|
||||
__all__ = [
|
||||
"configure_calendar_tools",
|
||||
"configure_collectives_tools",
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"""MCP tool definitions for Nextcloud Collectives app."""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
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,
|
||||
PageInfo,
|
||||
PageOperationResponse,
|
||||
SearchPagesResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
raw_collectives = await client.collectives.get_collectives()
|
||||
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)
|
||||
raw_pages = await client.collectives.get_pages(collective_id)
|
||||
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 from
|
||||
the page's collectivePath/filePath fields.
|
||||
|
||||
Args:
|
||||
collective_id: ID of the collective
|
||||
page_id: ID of the page
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
raw_page = await client.collectives.get_page(collective_id, page_id)
|
||||
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(parts)
|
||||
try:
|
||||
file_bytes, _ = await client.webdav.read_file(webdav_path)
|
||||
content = file_bytes.decode("utf-8")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to read page content via WebDAV: %s",
|
||||
webdav_path,
|
||||
)
|
||||
|
||||
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)
|
||||
raw_pages = await client.collectives.search_pages(collective_id, query)
|
||||
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)
|
||||
raw_tags = await client.collectives.get_tags(collective_id)
|
||||
tags = [CollectiveTag(**t) for t in raw_tags]
|
||||
return ListTagsResponse(tags=tags, total=len(tags))
|
||||
|
||||
@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
|
||||
) -> ListPagesResponse:
|
||||
"""List trashed pages in a Nextcloud Collective
|
||||
|
||||
Args:
|
||||
collective_id: ID of the collective
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
raw_pages = await client.collectives.get_trashed_pages(collective_id)
|
||||
pages = [PageInfo(**p) for p in raw_pages]
|
||||
return ListPagesResponse(
|
||||
pages=pages, total=len(pages), collective_id=collective_id
|
||||
)
|
||||
|
||||
# --- 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)
|
||||
raw = await client.collectives.create_collective(name, emoji)
|
||||
collective = Collective(**raw)
|
||||
return CreateCollectiveResponse(
|
||||
id=collective.id, name=collective.name, emoji=collective.emoji
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Update Collective",
|
||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("collectives:write")
|
||||
@instrument_tool
|
||||
async def collectives_update_collective(
|
||||
ctx: Context, collective_id: int, emoji: str | None = None
|
||||
) -> CollectiveOperationResponse:
|
||||
"""Update a Nextcloud Collective (emoji)
|
||||
|
||||
Args:
|
||||
collective_id: ID of the collective
|
||||
emoji: New emoji for the collective
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
await client.collectives.update_collective(collective_id, emoji)
|
||||
return CollectiveOperationResponse(
|
||||
collective_id=collective_id,
|
||||
status_code=200,
|
||||
message="Collective updated",
|
||||
)
|
||||
|
||||
@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 page's collectivePath/filePath to add content after creation.
|
||||
|
||||
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)
|
||||
raw = await client.collectives.create_page(collective_id, parent_id, title)
|
||||
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)
|
||||
await client.collectives.move_page(
|
||||
collective_id, page_id, parent_id, title, index, copy
|
||||
)
|
||||
action = "copied" if copy else "moved"
|
||||
return PageOperationResponse(
|
||||
page_id=page_id,
|
||||
collective_id=collective_id,
|
||||
status_code=200,
|
||||
message=f"Page {action}",
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Trash Collective Page",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, 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)
|
||||
|
||||
Args:
|
||||
collective_id: ID of the collective
|
||||
page_id: ID of the page to trash
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
await client.collectives.trash_page(collective_id, page_id)
|
||||
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)
|
||||
await client.collectives.restore_page(collective_id, page_id)
|
||||
return PageOperationResponse(
|
||||
page_id=page_id,
|
||||
collective_id=collective_id,
|
||||
status_code=200,
|
||||
message="Page restored from trash",
|
||||
)
|
||||
|
||||
@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)
|
||||
await client.collectives.set_page_emoji(collective_id, page_id, emoji)
|
||||
return PageOperationResponse(
|
||||
page_id=page_id,
|
||||
collective_id=collective_id,
|
||||
status_code=200,
|
||||
message="Page emoji updated",
|
||||
)
|
||||
|
||||
@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)
|
||||
raw = await client.collectives.create_tag(collective_id, name, color)
|
||||
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)
|
||||
await client.collectives.assign_tag(collective_id, page_id, tag_id)
|
||||
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
|
||||
|
||||
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)
|
||||
await client.collectives.remove_tag(collective_id, page_id, tag_id)
|
||||
return PageOperationResponse(
|
||||
page_id=page_id,
|
||||
collective_id=collective_id,
|
||||
status_code=200,
|
||||
message=f"Tag {tag_id} removed from page",
|
||||
)
|
||||
Reference in New Issue
Block a user