Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions app-hooks/post-installation/10-install-collectives-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash

set -euox pipefail

echo "Installing and configuring collectives app for testing..."

# Collectives depends on Circles (teams) - ensure it's enabled
# Circles is bundled with Nextcloud, so just enable it
php /var/www/html/occ app:enable circles

# Check if development collectives app is mounted at /opt/apps/collectives
if [ -d /opt/apps/collectives ]; then
echo "Development collectives app found at /opt/apps/collectives"

# Remove any existing collectives app in apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/collectives ]; then
echo "Removing existing collectives in apps..."
rm -rf /var/www/html/custom_apps/collectives
fi

# Create symlink from apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/collectives -> /opt/apps/collectives"
ln -sf /opt/apps/collectives /var/www/html/custom_apps/collectives

echo "Enabling collectives app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable collectives
elif [ -d /var/www/html/custom_apps/collectives ]; then
echo "collectives app directory found in apps (already installed)"
php /var/www/html/occ app:enable collectives
else
echo "collectives app not found, installing from app store..."
php /var/www/html/occ app:install collectives
php /var/www/html/occ app:enable collectives
fi
2 changes: 2 additions & 0 deletions nextcloud_mcp_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
)
from nextcloud_mcp_server.server import (
configure_calendar_tools,
configure_collectives_tools,
configure_contacts_tools,
configure_cookbook_tools,
configure_deck_tools,
Expand Down Expand Up @@ -1274,6 +1275,7 @@ async def nc_get_capabilities():
"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,
Expand Down
2 changes: 2 additions & 0 deletions nextcloud_mcp_server/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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
Expand Down Expand Up @@ -81,6 +82,7 @@ def __init__(self, base_url: str, username: str, auth: Auth | None = None):
) # 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)
Expand Down
310 changes: 310 additions & 0 deletions nextcloud_mcp_server/client/collectives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
"""Client for Nextcloud Collectives app API (OCS)."""

import logging
from typing import Any

from nextcloud_mcp_server.client.base import BaseNextcloudClient

logger = logging.getLogger(__name__)

API_BASE = "/ocs/v2.php/apps/collectives/api/v1.0"

_UNSET = object()
"""Sentinel to distinguish 'not provided' from an explicit None."""


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):
"""Client for Nextcloud Collectives app operations."""

app_name = "collectives"

_OCS_HEADERS: dict[str, str] = {
"OCS-APIRequest": "true",
"Accept": "application/json",
}

_OCS_HEADERS_JSON: dict[str, str] = {
**_OCS_HEADERS,
"Content-Type": "application/json",
}

def _unwrap_ocs(self, response_json: dict[str, Any]) -> Any:
"""Unwrap OCS envelope, validating the status before returning data."""
ocs = response_json.get("ocs")
if ocs is None:
raise OCSError(500, "Response is not an OCS envelope")
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)
if "data" not in ocs:
raise OCSError(500, "OCS response missing 'data' field")
return 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._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._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]

async def update_collective(
self, collective_id: int, emoji: str | None | object = _UNSET
) -> dict[str, Any]:
"""Update a collective (emoji).

Pass emoji=None to clear the emoji. Omit emoji entirely to leave
it unchanged.

Raises:
ValueError: If no fields are provided to update.
"""
json_data: dict[str, Any] = {}
if emoji is not _UNSET:
json_data["emoji"] = emoji
if not json_data:
raise ValueError("At least one field must be provided to update")
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}",
json=json_data,
headers=self._OCS_HEADERS_JSON,
)
data = self._unwrap_ocs(response.json())
return data["collective"]

async def trash_collective(self, collective_id: int) -> None:
"""Move a collective to trash (soft delete)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())

async def delete_collective(self, collective_id: int) -> None:
"""Permanently delete a collective (must be trashed first).

This is irreversible. The collective must be in the trash before
calling this method.
"""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())

# Trash (collectives)

async def get_trashed_collectives(self) -> list[dict[str, Any]]:
"""List trashed collectives."""
response = await self._make_request(
"GET", f"{API_BASE}/collectives/trash", headers=self._OCS_HEADERS
)
data = self._unwrap_ocs(response.json())
return data["collectives"]

async def restore_collective(self, collective_id: int) -> dict[str, Any]:
"""Restore a collective from trash."""
response = await self._make_request(
"PATCH",
f"{API_BASE}/collectives/trash/{collective_id}",
headers=self._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._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._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._OCS_HEADERS_JSON,
)
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._OCS_HEADERS_JSON,
)
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)."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())

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."""
# Sending {"emoji": null} intentionally clears the emoji on the server
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._OCS_HEADERS_JSON,
)
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._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._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._OCS_HEADERS_JSON,
)
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."""
response = await self._make_request(
"PUT",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())

async def remove_tag(self, collective_id: int, page_id: int, tag_id: int) -> None:
"""Remove a tag from a page."""
response = await self._make_request(
"DELETE",
f"{API_BASE}/collectives/{collective_id}/pages/{page_id}/tags/{tag_id}",
headers=self._OCS_HEADERS,
)
self._unwrap_ocs(response.json())

# 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._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._OCS_HEADERS,
)
data = self._unwrap_ocs(response.json())
return data["page"]
2 changes: 2 additions & 0 deletions nextcloud_mcp_server/models/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,7 @@ class UpdateScopesResponse(BaseResponse):
"sharing:write",
"news:read",
"news:write",
"collectives:read",
"collectives:write",
}
)
Loading
Loading