Skip to content

Commit d86dd6f

Browse files
committed
Add Collectives tools: list_collectives, get_collective_pages, get_collective_page, create_collective, create_collective_page, delete_collective, delete_collective_page
1 parent ef52177 commit d86dd6f

6 files changed

Lines changed: 480 additions & 0 deletions

File tree

.github/workflows/tests-integration.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ jobs:
7070
$OCC "php occ config:system:set ratelimit_overwrite files_sharing.shareapi.createshare user period --value=60 --type=integer"
7171
$OCC "php occ app:install spreed" || echo "spreed already installed"
7272
$OCC "php occ app:install announcementcenter" || echo "announcementcenter already installed"
73+
$OCC "php occ app:install collectives" || echo "collectives already installed"
7374
$OCC "php occ app:install mail"
7475
SMTP4DEV_IP=$(docker inspect ${{ job.services.smtp4dev.id }} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
7576
echo "smtp4dev IP: $SMTP4DEV_IP"

src/nc_mcp_server/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ async def ocs_delete(self, path: str) -> Any:
244244
result: dict[str, Any] = response.json() # type: ignore[assignment]
245245
return result["ocs"]["data"]
246246

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

249257
async def dav_propfind(self, path: str, depth: int = 1) -> list[dict[str, Any]]:

src/nc_mcp_server/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .tools import (
1010
activity,
1111
announcements,
12+
collectives,
1213
comments,
1314
files,
1415
mail,
@@ -50,6 +51,7 @@ def create_server(config: Config | None = None) -> FastMCP:
5051

5152
activity.register(mcp)
5253
announcements.register(mcp)
54+
collectives.register(mcp)
5355
comments.register(mcp)
5456
files.register(mcp)
5557
mail.register(mcp)
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Collectives tools — manage collectives and pages via OCS API."""
2+
3+
import json
4+
from typing import Any
5+
6+
from mcp.server.fastmcp import FastMCP
7+
8+
from ..annotations import ADDITIVE, DESTRUCTIVE, READONLY
9+
from ..permissions import PermissionLevel, require_permission
10+
from ..state import get_client
11+
12+
API = "apps/collectives/api/v1.0"
13+
14+
15+
def _format_collective(c: dict[str, Any]) -> dict[str, Any]:
16+
return {
17+
"id": c["id"],
18+
"name": c["name"],
19+
"emoji": c.get("emoji"),
20+
"level": c.get("level"),
21+
"can_edit": c.get("canEdit"),
22+
"can_share": c.get("canShare"),
23+
"page_mode": c.get("pageMode"),
24+
"user_page_order": c.get("userPageOrder"),
25+
}
26+
27+
28+
def _format_page(p: dict[str, Any]) -> dict[str, Any]:
29+
result: dict[str, Any] = {
30+
"id": p["id"],
31+
"title": p.get("title", ""),
32+
"emoji": p.get("emoji"),
33+
"timestamp": p.get("timestamp"),
34+
"size": p.get("size"),
35+
"file_name": p.get("fileName"),
36+
"file_path": p.get("filePath"),
37+
"last_user_id": p.get("lastUserId"),
38+
}
39+
if p.get("content") is not None:
40+
result["content"] = p["content"]
41+
if p.get("tags"):
42+
result["tags"] = p["tags"]
43+
return result
44+
45+
46+
def _register_read_tools(mcp: FastMCP) -> None:
47+
@mcp.tool(annotations=READONLY)
48+
@require_permission(PermissionLevel.READ)
49+
async def list_collectives() -> str:
50+
"""List all collectives the current user has access to.
51+
52+
Collectives are shared knowledge bases with wiki-style pages.
53+
Each collective has a landing page and may contain nested subpages.
54+
55+
Returns:
56+
JSON list of collectives with id, name, emoji, permissions.
57+
"""
58+
client = get_client()
59+
data = await client.ocs_get(f"{API}/collectives")
60+
collectives = [_format_collective(c) for c in data.get("collectives", data if isinstance(data, list) else [])]
61+
return json.dumps(collectives, indent=2, default=str)
62+
63+
@mcp.tool(annotations=READONLY)
64+
@require_permission(PermissionLevel.READ)
65+
async def get_collective_pages(collective_id: int) -> str:
66+
"""List all pages in a collective.
67+
68+
Returns the full page tree including the landing page and all subpages.
69+
Each page has a title, emoji, timestamp, size, and file path.
70+
71+
Args:
72+
collective_id: The numeric collective ID. Use list_collectives to find IDs.
73+
74+
Returns:
75+
JSON list of pages with id, title, emoji, timestamp, size, file_name, file_path.
76+
"""
77+
client = get_client()
78+
data = await client.ocs_get(f"{API}/collectives/{collective_id}/pages")
79+
pages = [_format_page(p) for p in data.get("pages", data if isinstance(data, list) else [])]
80+
return json.dumps(pages, indent=2, default=str)
81+
82+
@mcp.tool(annotations=READONLY)
83+
@require_permission(PermissionLevel.READ)
84+
async def get_collective_page(collective_id: int, page_id: int) -> str:
85+
"""Get a single page from a collective, including its content.
86+
87+
Returns full page details with the Markdown content of the page.
88+
89+
Args:
90+
collective_id: The numeric collective ID.
91+
page_id: The numeric page ID. Use get_collective_pages to find IDs.
92+
93+
Returns:
94+
JSON object with page details including content (Markdown).
95+
"""
96+
client = get_client()
97+
data = await client.ocs_get(f"{API}/collectives/{collective_id}/pages/{page_id}")
98+
page = data.get("page", data)
99+
return json.dumps(_format_page(page), indent=2, default=str)
100+
101+
102+
def _register_write_tools(mcp: FastMCP) -> None:
103+
@mcp.tool(annotations=ADDITIVE)
104+
@require_permission(PermissionLevel.WRITE)
105+
async def create_collective(name: str, emoji: str | None = None) -> str:
106+
"""Create a new collective (shared knowledge base).
107+
108+
A collective is a wiki-like space where team members can create and
109+
edit pages together. It automatically creates a landing page.
110+
111+
Args:
112+
name: Name of the collective (required, must be unique).
113+
emoji: Optional emoji icon for the collective (e.g. "📚").
114+
115+
Returns:
116+
JSON object with the created collective details.
117+
"""
118+
if not name.strip():
119+
raise ValueError("Collective name cannot be empty.")
120+
client = get_client()
121+
post_data: dict[str, Any] = {"name": name}
122+
if emoji:
123+
post_data["emoji"] = emoji
124+
data = await client.ocs_post_json(f"{API}/collectives", json_data=post_data)
125+
collective = data.get("collective", data)
126+
return json.dumps(_format_collective(collective), indent=2, default=str)
127+
128+
@mcp.tool(annotations=ADDITIVE)
129+
@require_permission(PermissionLevel.WRITE)
130+
async def create_collective_page(collective_id: int, parent_id: int, title: str) -> str:
131+
"""Create a new page in a collective.
132+
133+
Pages are Markdown documents organized in a tree structure.
134+
Every page must have a parent — use the landing page ID as parent
135+
for top-level pages.
136+
137+
Args:
138+
collective_id: The numeric collective ID.
139+
parent_id: Parent page ID. Use the landing page ID from
140+
get_collective_pages for top-level pages.
141+
title: Title of the new page (required).
142+
143+
Returns:
144+
JSON object with the created page details.
145+
"""
146+
if not title.strip():
147+
raise ValueError("Page title cannot be empty.")
148+
client = get_client()
149+
data = await client.ocs_post_json(
150+
f"{API}/collectives/{collective_id}/pages/{parent_id}",
151+
json_data={"title": title},
152+
)
153+
page = data.get("page", data)
154+
return json.dumps(_format_page(page), indent=2, default=str)
155+
156+
157+
def _register_destructive_tools(mcp: FastMCP) -> None:
158+
@mcp.tool(annotations=DESTRUCTIVE)
159+
@require_permission(PermissionLevel.DESTRUCTIVE)
160+
async def delete_collective(collective_id: int) -> str:
161+
"""Delete a collective permanently (trash + permanent delete).
162+
163+
This moves the collective to trash, then permanently deletes it
164+
along with all its pages. This action is irreversible.
165+
166+
Args:
167+
collective_id: The numeric collective ID.
168+
169+
Returns:
170+
Confirmation message.
171+
"""
172+
client = get_client()
173+
await client.ocs_delete(f"{API}/collectives/{collective_id}")
174+
await client.ocs_delete(f"{API}/collectives/trash/{collective_id}")
175+
return f"Collective {collective_id} deleted permanently."
176+
177+
@mcp.tool(annotations=DESTRUCTIVE)
178+
@require_permission(PermissionLevel.DESTRUCTIVE)
179+
async def delete_collective_page(collective_id: int, page_id: int) -> str:
180+
"""Delete a page from a collective permanently (trash + permanent delete).
181+
182+
This removes the page and its content. This action is irreversible.
183+
The landing page of a collective cannot be deleted.
184+
185+
Args:
186+
collective_id: The numeric collective ID.
187+
page_id: The numeric page ID to delete.
188+
189+
Returns:
190+
Confirmation message.
191+
"""
192+
client = get_client()
193+
await client.ocs_delete(f"{API}/collectives/{collective_id}/pages/{page_id}")
194+
await client.ocs_delete(f"{API}/collectives/{collective_id}/pages/trash/{page_id}")
195+
return f"Page {page_id} deleted permanently from collective {collective_id}."
196+
197+
198+
def register(mcp: FastMCP) -> None:
199+
"""Register Collectives tools with the MCP server."""
200+
_register_read_tools(mcp)
201+
_register_write_tools(mcp)
202+
_register_destructive_tools(mcp)

0 commit comments

Comments
 (0)