Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .github/workflows/tests-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
$OCC "php occ config:system:set ratelimit_overwrite files_sharing.shareapi.createshare user period --value=60 --type=integer"
$OCC "php occ app:install spreed" || echo "spreed already installed"
$OCC "php occ app:install announcementcenter" || echo "announcementcenter already installed"
$OCC "php occ app:install collectives" || echo "collectives already installed"
Comment thread
oleksandr-nc marked this conversation as resolved.
$OCC "php occ app:install mail"
SMTP4DEV_IP=$(docker inspect ${{ job.services.smtp4dev.id }} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
echo "smtp4dev IP: $SMTP4DEV_IP"
Expand Down
8 changes: 8 additions & 0 deletions src/nc_mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ async def ocs_delete(self, path: str) -> Any:
result: dict[str, Any] = response.json() # type: ignore[assignment]
return result["ocs"]["data"]

async def ocs_patch(self, path: str, data: dict[str, Any] | None = None) -> Any:
"""Make an OCS PATCH request and return the data portion."""
url = f"{self._base_url}/ocs/v2.php/{path}"
response = await self._do_request("PATCH", url, data=data or {})
_raise_for_ocs_status(response, f"OCS PATCH {path}")
result: dict[str, Any] = response.json() # type: ignore[assignment]
return result["ocs"]["data"]

# --- WebDAV ---

async def dav_propfind(self, path: str, depth: int = 1) -> list[dict[str, Any]]:
Expand Down
2 changes: 2 additions & 0 deletions src/nc_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .tools import (
activity,
announcements,
collectives,
comments,
files,
mail,
Expand Down Expand Up @@ -50,6 +51,7 @@ def create_server(config: Config | None = None) -> FastMCP:

activity.register(mcp)
announcements.register(mcp)
collectives.register(mcp)
comments.register(mcp)
files.register(mcp)
mail.register(mcp)
Expand Down
268 changes: 268 additions & 0 deletions src/nc_mcp_server/tools/collectives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
"""Collectives tools — manage collectives and pages via OCS API."""

import json
from typing import Any

from mcp.server.fastmcp import FastMCP

from ..annotations import ADDITIVE, DESTRUCTIVE, READONLY
from ..permissions import PermissionLevel, require_permission
from ..state import get_client

API = "apps/collectives/api/v1.0"


def _format_collective(c: dict[str, Any]) -> dict[str, Any]:
return {
"id": c["id"],
"name": c["name"],
"emoji": c.get("emoji"),
"level": c.get("level"),
"can_edit": c.get("canEdit"),
"can_share": c.get("canShare"),
"page_mode": c.get("pageMode"),
"user_page_order": c.get("userPageOrder"),
}


def _format_page(p: dict[str, Any]) -> dict[str, Any]:
return {
"id": p["id"],
"title": p.get("title", ""),
"emoji": p.get("emoji"),
"timestamp": p.get("timestamp"),
"size": p.get("size"),
"file_name": p.get("fileName"),
"file_path": p.get("filePath"),
"last_user_id": p.get("lastUserId"),
"tags": p.get("tags", []),
}
Comment on lines +28 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a page-detail serializer for get_collective_page().

get_collective_page() promises the page content, but _format_page() only keeps metadata. As written, single-page reads always strip the body from the OCS payload.

🛠️ Suggested fix
 def _format_page(p: dict[str, Any]) -> dict[str, Any]:
     return {
         "id": p["id"],
         "title": p.get("title", ""),
         "emoji": p.get("emoji"),
         "timestamp": p.get("timestamp"),
         "size": p.get("size"),
         "file_name": p.get("fileName"),
         "file_path": p.get("filePath"),
         "last_user_id": p.get("lastUserId"),
         "tags": p.get("tags", []),
     }
+
+
+def _format_page_detail(p: dict[str, Any]) -> dict[str, Any]:
+    detail = _format_page(p)
+    for key, value in p.items():
+        if key not in {"id", "title", "emoji", "timestamp", "size", "fileName", "filePath", "lastUserId", "tags"}:
+            detail[key] = value
+    return detail
...
-        page = data.get("page", data)
-        return json.dumps(_format_page(page), indent=2, default=str)
+        page = data.get("page", data)
+        return json.dumps(_format_page_detail(page), indent=2, default=str)

Also applies to: 92-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/nc_mcp_server/tools/collectives.py` around lines 28 - 39, _format_page
currently strips the page body and only returns metadata, causing
get_collective_page() to lose the page content; add a page-detail serializer (or
extend _format_page with an include_body flag) that preserves the body/content
field (e.g., "body" or "content" from the OCS payload) and use that serializer
in get_collective_page() instead of the metadata-only _format_page; also replace
the other single-page return sites (the code referenced around the second
occurrence) to use the detail serializer so single-page reads include the full
content.



def _register_read_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=READONLY)
@require_permission(PermissionLevel.READ)
async def list_collectives() -> str:
"""List all collectives the current user has access to.

Collectives are shared knowledge bases with wiki-style pages.
Each collective has a landing page and may contain nested subpages.

Returns:
JSON list of collectives with id, name, emoji, permissions.
"""
client = get_client()
data = await client.ocs_get(f"{API}/collectives")
collectives = [_format_collective(c) for c in data["collectives"]]
return json.dumps(collectives, indent=2, default=str)

@mcp.tool(annotations=READONLY)
@require_permission(PermissionLevel.READ)
async def get_collective_pages(collective_id: int) -> str:
"""List all pages in a collective.

Returns the full page tree including the landing page and all subpages.
Each page has a title, emoji, timestamp, size, and file path.

Args:
collective_id: The numeric collective ID. Use list_collectives to find IDs.

Returns:
JSON list of pages with id, title, emoji, timestamp, size, file_name, file_path.
"""
client = get_client()
data = await client.ocs_get(f"{API}/collectives/{collective_id}/pages")
pages = [_format_page(p) for p in data["pages"]]
return json.dumps(pages, indent=2, default=str)

@mcp.tool(annotations=READONLY)
@require_permission(PermissionLevel.READ)
async def get_collective_page(collective_id: int, page_id: int) -> str:
"""Get a single page from a collective, including its content.

Returns full page details with the Markdown content of the page.

Args:
collective_id: The numeric collective ID.
page_id: The numeric page ID. Use get_collective_pages to find IDs.

Returns:
JSON object with page details including content (Markdown).
"""
client = get_client()
data = await client.ocs_get(f"{API}/collectives/{collective_id}/pages/{page_id}")
page = data["page"]
return json.dumps(_format_page(page), indent=2, default=str)


def _register_write_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=ADDITIVE)
@require_permission(PermissionLevel.WRITE)
async def create_collective(name: str, emoji: str | None = None) -> str:
"""Create a new collective (shared knowledge base).

A collective is a wiki-like space where team members can create and
edit pages together. It automatically creates a landing page.

Args:
name: Name of the collective (required, must be unique).
emoji: Optional emoji icon for the collective (e.g. "📚").

Returns:
JSON object with the created collective details.
"""
if not name.strip():
raise ValueError("Collective name cannot be empty.")
client = get_client()
post_data: dict[str, Any] = {"name": name}
if emoji:
post_data["emoji"] = emoji
data = await client.ocs_post_json(f"{API}/collectives", json_data=post_data)
collective = data["collective"]
return json.dumps(_format_collective(collective), indent=2, default=str)

@mcp.tool(annotations=ADDITIVE)
@require_permission(PermissionLevel.WRITE)
async def create_collective_page(collective_id: int, parent_id: int, title: str) -> str:
"""Create a new page in a collective.

Pages are Markdown documents organized in a tree structure.
Every page must have a parent — use the landing page ID as parent
for top-level pages.

Args:
collective_id: The numeric collective ID.
parent_id: Parent page ID. Use the landing page ID from
get_collective_pages for top-level pages.
title: Title of the new page (required).

Returns:
JSON object with the created page details.
"""
if not title.strip():
raise ValueError("Page title cannot be empty.")
client = get_client()
data = await client.ocs_post_json(
f"{API}/collectives/{collective_id}/pages/{parent_id}",
json_data={"title": title},
)
page = data["page"]
return json.dumps(_format_page(page), indent=2, default=str)


def _register_destructive_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=DESTRUCTIVE)
@require_permission(PermissionLevel.DESTRUCTIVE)
async def trash_collective(collective_id: int) -> str:
"""Move a collective to the trash.

The collective and its pages are soft-deleted. Use
restore_collective to undo, or delete_collective to
permanently remove it.

Args:
collective_id: The numeric collective ID.

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_delete(f"{API}/collectives/{collective_id}")
return f"Collective {collective_id} moved to trash."

@mcp.tool(annotations=ADDITIVE)
@require_permission(PermissionLevel.WRITE)
async def restore_collective(collective_id: int) -> str:
"""Restore a collective from the trash.

Args:
collective_id: The numeric collective ID (from list_collectives or prior trash operation).

Returns:
JSON object with the restored collective details.
"""
client = get_client()
data = await client.ocs_patch(f"{API}/collectives/trash/{collective_id}")
collective = data["collective"]
return json.dumps(_format_collective(collective), indent=2, default=str)

@mcp.tool(annotations=DESTRUCTIVE)
@require_permission(PermissionLevel.DESTRUCTIVE)
async def delete_collective(collective_id: int) -> str:
"""Permanently delete a collective from the trash.

The collective must be in the trash first (use trash_collective).
This action is irreversible — all pages are permanently removed.

Args:
collective_id: The numeric collective ID.

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_delete(f"{API}/collectives/trash/{collective_id}")
return f"Collective {collective_id} deleted permanently."

@mcp.tool(annotations=DESTRUCTIVE)
@require_permission(PermissionLevel.DESTRUCTIVE)
async def trash_collective_page(collective_id: int, page_id: int) -> str:
"""Move a page to the collective's trash.

The page is soft-deleted. Use restore_collective_page to undo,
or delete_collective_page to permanently remove it.
The landing page cannot be trashed.

Args:
collective_id: The numeric collective ID.
page_id: The numeric page ID.

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_delete(f"{API}/collectives/{collective_id}/pages/{page_id}")
return f"Page {page_id} moved to trash."

@mcp.tool(annotations=ADDITIVE)
@require_permission(PermissionLevel.WRITE)
async def restore_collective_page(collective_id: int, page_id: int) -> str:
"""Restore a page from the collective's trash.

Args:
collective_id: The numeric collective ID.
page_id: The numeric page ID.

Returns:
JSON object with the restored page details.
"""
client = get_client()
data = await client.ocs_patch(f"{API}/collectives/{collective_id}/pages/trash/{page_id}")
page = data["page"]
return json.dumps(_format_page(page), indent=2, default=str)

@mcp.tool(annotations=DESTRUCTIVE)
@require_permission(PermissionLevel.DESTRUCTIVE)
async def delete_collective_page(collective_id: int, page_id: int) -> str:
"""Permanently delete a page from the collective's trash.

The page must be in the trash first (use trash_collective_page).
This action is irreversible.

Args:
collective_id: The numeric collective ID.
page_id: The numeric page ID.

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_delete(f"{API}/collectives/{collective_id}/pages/trash/{page_id}")
return f"Page {page_id} deleted permanently."


def register(mcp: FastMCP) -> None:
"""Register Collectives tools with the MCP server."""
_register_read_tools(mcp)
_register_write_tools(mcp)
_register_destructive_tools(mcp)
Loading